跨平台多媒体渲染引擎OPR简介

Posted 阿里巴巴文娱技术

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了跨平台多媒体渲染引擎OPR简介相关的知识,希望对你有一定的参考价值。

背景

在最新的优酷版本中已经支持了基于端侧实时人体识别的弹幕穿人能力,该能力大致可以分解为视频渲染模块、视频画面识别前处理模块、弹幕mask文件离屏合成模块、弹幕渲染模块,而这些模块正是搭载在我们构建的跨平台渲染引擎OPR上。
其实弹幕穿人对于多媒体播放场景来说只能算较小应用之一,我们甚至可以在弹幕渲染里就罗列出更多的特效例如:3D弹幕、多并发的动态弹幕、需要音视频信息实时配合的节奏弹幕等等;而在音视频渲染领域我们也不仅有画面识别的前处理、更多还有类似超分、插帧、音视频增强、色弱、护眼等观影模式的后处理支持。在多媒体播放这种“争分夺秒”的的场景下如何高效的实现以及组织上述功能,甚至可以实时对渲染效果做检测和统计,以及对于未来视频游戏化、互动化留有空间和技术储备,都是我们需要考虑的,正是基于这种考虑我们设计了跨平台的多媒体渲染引擎OPR,来支撑我们的构想。

OPR架构设计

从功能上来说我们需要将音视频前处理、后处理、渲染,2D(弹幕)渲染,3D渲染,互动及画面检测等能力集成到一起,从特性上来说我们需要兼顾高性能、热插拔、高可维护。纵观市面成熟的引擎,其实我们找不到一款符合上述要求的,GPUImage更多关注的是视频后处理,并且其跨平台实现需要不同技术栈;SDL跨平台技术栈相同但是更多实现的是音视频的渲染,无法基于其实现前后处理的扩展;像FlameMaster更是只实现了android端的弹幕渲染,局限性太大,无法扩展出炫彩的特效。深究我们提到的2D渲染能力、互动性等其实是一般游戏引擎具有的特点,并且视频游戏化也是未来的方向,基于此我们也需要将游戏引擎纳入我们的考虑,但是游戏引擎存在一个致命的问题就是从基因里就没有考虑过音视频后处理的事情,而后处理一般需要多个复杂算法的串并联执行,需要特殊的设计才能实现。
另外考虑到渲染的高性能需求,我们需要使用native的GPU渲染,在借鉴了cocos2D、GPUImage、SDL等引擎的情况下我们设计出了OPR的基本架构,如图:

可以看出,音频的跨平台实现相对比较容易,音频后处理及渲染均是在CPU中完成,且大部分平台提供的渲染接口也都是基于native的(Android端audiotrack需要通过jni反射Java层接口),同时音频处理算法复杂度和算力消耗相对视频来说均不在一个量级,这些都对我们跨平台的封装提供了便利。但是图像的处理及渲染的难度就不可同日而语,考虑到图像的计算量和并发特性我们最好使用GPU进行计算,而不同端的渲染协议接口和脚本语言又不尽相同,经统计如果要支持Android、ios、macOS、Windows这4种主流系统,我们需要对至少3种编程语言(c++、Java、oc)和3种脚本语言(glsl、msl、hlsl)进行封装。如何屏蔽不同平台渲染协议的特性和语言差异设计出一套统一的流程和接口就成为了我们实现跨平台高性能渲染引擎的重点,最终我们设计出如图架构:

如上,我们基于两个维度来封装不同平台的渲染协议:渲染流程和渲染要素。对于渲染流程我们划分了渲染最小单元:renderPass,其对应了一个render command的执行,在实际意义上可能意味着单条弹幕的渲染。对于渲染要素在抽取不同协议的共同点之后我们形成了:buffer、shader、program、texture、env、device、utils 7大组成。其中env负责本地UI系统与渲染协议的桥接,例如Android下需要egl连接surfaceView和OpenGL ES,utils负责将架构统一的标准翻译为不同协议各自的方式,例如OPRPixelFormat::RGBA8888对于OpenGL ES意味着GL_RGBA,对于metal则意味着MTLPixelFormatRGBA8Unorm,utils负责保存和屏蔽这些映射关系;而device是一个工厂类,负责生产其他5大渲染要素,以此来降低不同模块对渲染要素的依赖复杂度。渲染流程通过command来和渲染要素进行链接,command中的type用来决定是否需要微调渲染流程,zOrder则决定了该command的执行顺序,blend在描述不同command渲染结果的叠加方式,colorAttachment指定了渲染结果的保存对象,programState保存了渲染所需的要素及其对应的值。commandbuffer通过拆解command上述因素做出具体的执行。最终render通过封装commandBuffer和commandQueue实现了基于命令流的渲染,做到了技术实现和业务彻底解耦。

基于native UI的弹幕渲染

上文中我们已经介绍了如何打造跨平台的音画渲染器,而图像渲染本身其实就是一个可视化的过程,所以我们对于图像渲染的能力封装就是提供可视化控件。这样做的好处有:
●功能解耦,降低了单个功能开发复杂度的同时也提高了稳定性,避免了新功能的开发对存量功能的影响;
●类UI控件的设计更符合业务同学的开发习惯,降低使用难度;
●尽可能小的功能划分可以提升复用率,有利于控制代码规模和调试难度;
●基于UI控件的交互更贴近用户使用习惯,因为我们要打造的是一款可交互的多媒体渲染引擎,而不是单纯的展示;
到这里可能又带来了很多疑惑,既然我们最终提供的是UI控件的功能封装,为什么不直接使用原生UI控件或者QT、flutter这种跨平台的UI系统呢?首先是因为既然已经说明是原生则注定是生长在特定的系统里,高度依赖特定系统的上下文,当业务复杂到一定程度的时候这种跨平台的功能迁移是无法承受的;其次原生UI性能在高并发任务下存在不足,且大部分的UI还是偏向于展示型,我们提供的UI更侧重特效;然后就是性能,我们基于GPU渲染的UI控件可有效降低CPU使用和内存占用,流畅度提升。而至于QT和flutter则是有点杀鸡用牛刀的意味,并且在多媒体处理及渲染领域也不会有我们专业,我们更聚焦!
回到UI控件的问题,我们认为其在多媒体相关的使用场景下具有三大要素:样式、布局能力、交互。样式既外观,这也是我们最擅长的领域,利用shader我们可以写出非常酷炫的样式,这里只有想不到没有做不到,而布局主要解决控件的位置关系、顺序关系和嵌套关系,交互意味着控件可以接受输入可以产出输出。但是一般的基于GPU的渲染都比较依赖上下文,这决定了我们不可能设计出真实类似于原生的UI系统,这里我们借鉴了游戏引擎的理念,引入了director和scene的概念。Director可以看做是一个timer的主体,像OpenGL这种强线程要求的,我们可以利用director将其进行约束,确保我们所有的OpenGL提交都是在一个线程里,而scene可以看做是一个容器,容纳了需要显示的控件,切换scene可以实现不同页面的切换。最终综合上述因素我们设计出了如下的nativeUI系统:

在完成nativeUI系统的构建后,弹幕引擎相对来说就是水到渠成的工作了,首先创建timer对director的render进行驱动,这里我们可以设置常用的60HZ,或者特色能力90、120hz,进行按需设置。具体到单个的弹幕我们可以用sprite控件进行图片例如JPG、png的展示,利用animated sprite控件进行对GIF、apng等动图的展示,而label则负责文字展示,借用系统或者freetype我们可以完成不同字体的展示。如果我们需要更复杂的单体弹幕特效展示,则可以重新继承node,通过控件组合或者专项开发来完成,这些操作都是简单而高效的。而整体的弹幕特效的切换则可以通过切换scene来实现,在正在情况下label、sprite等单体字幕都是以scene的child的方式进行管理,在需要整体切换至类似打call等特效时我们可以在普通scene和effect scene来实现平滑切换,甚至是定制过度效果。

既要又要还要的音视频渲染

上文中我们描述了如何构建基于GPU渲染的nativeUI系统,并且在该系统上跑通了弹幕能力,接下来我们就需要考虑一个更基本的场景,如何在前述的条件下跑通音视频处理及渲染能力。就视频处理及渲染而言,其不具备弹幕渲染那么明显的UI特性,没有复杂条目并行处理及多条目整体特效切换等需求,但这不意味着复杂度的降低,反而因为视频画面清晰度日益增长(目前移动端1080P已经普及,4K在某些平台也可以看到),画面增强、风格化、插帧等功能不断涌现,不同平台软硬解、不同格式数据的兼容等都为视频渲染带来了 更大的难度。它既要保证高可复用的功能组合、动态插拔,又要保证对绝大部分机型的覆盖和最低的性能要求,还要留有未来视频互动化游戏化的余地,在整个多媒体播放的技术链路中也是属于绝对的技术高地。
虽然我们提到了很多需要解决的难题,但是设计一个高性能、多功能、高可扩展的视频渲染框架仍是我们的乐趣所在。首先我们借鉴GPUImage进行了功能的filter封装,这可以解决我们链式的功能叠加,而通过twopass filter以及group filter的扩展我们更是实现了filter的串并联,从而实现了图式的功能复合使用。而filter封装了command,承载了基本的渲染能力,这也是和弹幕渲染不同之处,弹幕以控件维度进行了command封装,视频渲染则更细化到了filter的维度。然后我们构建了render pipeline,带有工厂属性,可以在播中动态创建filter插入到当前的pipeline中,这样我们就解决了功能复用和动态插拔的问题。
现在我们需要解决的是如何使视频渲染具备基本的交互能力以及进一步的“改装”空间。在一般的视频渲染场景里,一个上屏surface对应一个播放实例也对应一个渲染实例,在这里的渲染实例我们可以定义为一个videoLayer,这个videoLayer继承自上文提到的node,如果需要得到鼠标点触等互动事件则可以再继承eventLayer,而videoLayer内部则封装了我们提到的pipeline,这样我们的渲染实例在整体上以一个控件的形式融入到了我们的整体nativeUI架构中,得到了布局、事件交互等UI特性,对内的pipeline封装则保证了其所需的复杂处理链路,最终videoLayer不同于其他简单控件封装一个command,而是对外提供了一个command序列,通过order来组织其执行顺序。
而对于高性能的保障是从我们的设计理念出发,贯穿于我们的实现过程,体现在各个细节。自底向上,我们从一开始就选择基于GPU的计算,保证了低CPU占用、高并发性能,在实现上我们的核心代码均采用C++实现,保证平台复用的同时,极大的提升了性能,在细节上pipeline o(1)复杂的的查找算法、位运算、代码块复用及性能提取都是我们为性能做出的努力,最终我们设计出如图视频渲染架构:

视频渲染构建的另外一个难点是需要兼容不同平台的硬解,iOS的vtb解码可以直接吐出pixelbuffer,可以直接呈现数据,但是类似Android mediacodec、Windows平台为了保障性能是不建议直接读取到内存再进行渲染。在这种情况我们构建了基于texture的surfacewrap,数据直接更新至纹理,这样我们就可以提供我们的后处理能力,通过这种方式我们使得不同的系统播放器可以接入OPR,从而另系统播放器也可以支持我们特色的护眼、超分、插帧、截图等后处理能力。

监控链路为体验保驾护航

在我们完成上述功能之后需要另外考虑的问题就是效果如何,在这里我们需要定义如何来衡量效果的好坏。一般我们认为良好的效果就是是否如实的还原了需要展示的音视频内容,并且展示过程是否流畅。据此我们规划了基于内容和基于流程的两种监控方式。对于内容监控,我们从客诉出发总结最被诟病的视频渲染异常为黑屏、花屏、绿屏等,对于音频则是音量或者静音,针对这些我们的监控系统支持按配置以一定间隔对音视频进行对应的检测。对于流程监控,我们从内存占用和平均渲染时长进行统计,其中内存我们可细化至显存、内存堆、栈的分别统计,帮助我们及时了解某部分内存的突出占用来解决内存IO引起的卡顿问题,而针对渲染时长的统计可以帮助我们定位是否存在某些计算量大的filter影响流畅度,针对上述异常我们也可以最初一些针对性的恢复措施。

未来展望

虽然我们已经完成了一些工作但是还有很多需要做的事情,例如目前我们还没有针对Android平台的Vulkan支持,对于VR的支持还是依赖第三方库,还需要探究更多互动和视频渲染的结合,不依赖底层开发的特效支持,简单编辑器能力等。相信OPR的未来会变得更好。

了解3D世界的黑魔法 - 纯Java构造一个简单的3D渲染引擎

前言

当今用于游戏和多媒体的3D渲染引擎在数学和编程的复杂性上足以令大多数人望而生畏,从编程接口的OpenGL再到逼真到令人叹为观止的UE5(虚幻五)引擎,后者单单引擎本身(不含调试)的大小就达到了将近40g(当然UE5不光只有渲染的功能),其中带来的全新的核心的Nanite虚拟微多边形几何技术和Lumen动态全局光照技术更是及其复杂。

对于非渲染引擎相关工作的开发者来说,可能认为即使构建最简单的3D程序也非常困难,但事实上并非如此,本篇文章将通过简单的200多行的纯 Java代码,去实践正交投影、简单三角形光栅化、z缓冲(深度缓冲区)和平面着色等基本的3D渲染技术,然后在下一片文章中,将着重介绍光线追踪的知识。

当然本篇文章最终实现的“3D渲染引擎”非常简单,没有做任何的算法优化,而且仅使用到了CPU,实际性能远不如OpenGl。不过其目的是用于去帮我们了解真正的现代引擎是如何发挥它们的黑魔法,以便更好的上手使用它们。

需要的知识储备

三角函数、矩阵运算、向量运算、法向量。

如果你尚未学习或者忘记了以上的知识也不用担心,本篇文章中会结合例子对上述知识进行简单的解释,同时也不必太过纠结这些数学知识,会用即可,毕竟连卡神也会“what the fuck?”。

当然如果熟悉上述知识,阅读起来会更加轻松。

目标

我们将会绘制一个四面体,因为它是最简单的3D图形~

界面

用于展示图形的界面

public static void main(String[] args) 
        JFrame frame = new JFrame();
        Container pane = frame.getContentPane();
        pane.setLayout(new BorderLayout());
       
        // panel to display render results
        JPanel renderPanel = new JPanel() 
            public void paintComponent(Graphics g) 
                Graphics2D g2 = (Graphics2D) g;
                g2.setColor(Color.BLACK);
                g2.fillRect(0, 0, getWidth(), getHeight());
                
                // rendering magic will happen here
            
        ;
        pane.add(renderPanel, BorderLayout.CENTER);
        
        frame.setSize(600, 600);
        frame.setVisible(true);
    

基础

坐标系

点与平面

现在让我们添加一些3D世界的基本的模型类——顶点和三角形。Vertex 只是一个简单的结构来存储我们的三个坐标(X、Y 和 Z),而三角形将三个顶点绑定在一起并存储它的颜色。

// X 坐标表示左右方向的移动
// Y 表示屏幕上的上下移动
// Z 表示深度(因此 Z 轴垂直于您的屏幕)。正 Z 表示“朝向观察者”。
class Vertex 
    double x;
    double y;
    double z;
    Vertex(double x, double y, double z) 
        this.x = x;
        this.y = y;
        this.z = z;
    


class Triangle 
    Vertex v1;
    Vertex v2;
    Vertex v3;
    Color color;
    Triangle(Vertex v1, Vertex v2, Vertex v3, Color color) 
        this.v1 = v1;
        this.v2 = v2;
        this.v3 = v3;
        this.color = color;
    

那么为什么要使用三角形来描述3D世界呢?

a.三角形是最简单的多边形,少于3个顶点就不能成为一个表面
b.三角形必然是平坦的
c.三角形经多种转换之后,仍然是三角形,这对于仿射转换和透视转换也成立。最坏的情况下,从三角形的边去看,三角形会退化为线段。在其它角度观察,仍能维持是三角形
d.它可以很好地用叉积判断一个点是不是在三角形内部(三角形的内外定义特别清晰)
e.几乎所有商用图形加速硬件都是为三角形光栅化而设计的

构造目标三维图形

非常简单,就是四个三角形合并而成(先将它们放入列表)。同时为了区分它们,赋予不同的颜色。

List tris = new ArrayList<>();
tris.add(new Triangle(new Vertex(100, 100, 100),
                      new Vertex(-100, -100, 100),
                      new Vertex(-100, 100, -100),
                      Color.WHITE));
tris.add(new Triangle(new Vertex(100, 100, 100),
                      new Vertex(-100, -100, 100),
                      new Vertex(100, -100, -100),
                      Color.RED));
tris.add(new Triangle(new Vertex(-100, 100, -100),
                      new Vertex(100, -100, -100),
                      new Vertex(100, 100, 100),
                      Color.GREEN));
tris.add(new Triangle(new Vertex(-100, 100, -100),
                      new Vertex(100, -100, -100),
                      new Vertex(-100, -100, 100),
                      Color.BLUE));

现在将它们放置到我们之前的界面中,不过先只展示框线。因为是正交投影,所以非常简单,忽略z轴绘制连线即可。

框线仅是用于目前直观的看到四面体,最终渲染的时候不会用到此2dAPI

// 生成的形状以原点 (0, 0, 0) 为中心,稍后我们将围绕该点进行旋转。
g2.translate(getWidth() / 2, getHeight() / 2);
g2.setColor(Color.WHITE);
for (Triangle t : tris) 
    Path2D path = new Path2D.Double();
    path.moveTo(t.v1.x, t.v1.y);
    path.lineTo(t.v2.x, t.v2.y);
    path.lineTo(t.v3.x, t.v3.y);
    path.closePath();
    g2.draw(path);

我们将得到如下结果:

这就是我们的四面体,为了让你相信,我们来为其添加一些旋转。

旋转

处理 3d 点的方法有很多,但最灵活的是使用矩阵乘法。将点表示为 3x1 向量,然后转换就是简单地乘以 3x3 矩阵。

例如两倍缩放:

当然,本次重点讲解的是旋转,3D 空间中的任何旋转都可以表示为 3 种原始旋转的组合:XY 平面旋转、YZ 平面旋转和 XZ 平面旋转。我们可以为每个旋转写出变换矩阵,如下所示:

同时矩阵变换还有这样的特性:

即多次矩阵变换可以预先先合并为一个。

看看通过代码如何实现矩阵和矩阵的乘法:

class Matrix3 
    double[] values;
    Matrix3(double[] values) 
        this.values = values;
    
    Matrix3 multiply(Matrix3 other) 
        double[] result = new double[9];
        for (int row = 0; row < 3; row++) 
            for (int col = 0; col < 3; col++) 
                for (int i = 0; i < 3; i++) 
                    result[row * 3 + col] +=
                        this.values[row * 3 + i] * other.values[i * 3 + col];
                
            
        
        return new Matrix3(result);
    
    Vertex transform(Vertex in) 
        return new Vertex(
            in.x * values[0] + in.y * values[3] + in.z * values[6],
            in.x * values[1] + in.y * values[4] + in.z * values[7],
            in.x * values[2] + in.y * values[5] + in.z * values[8]
        );
    

构建XZ平面(以Y为轴左右)旋转和YZ平面(以X为轴上下)旋转。

double heading = Math.toRadians(x[0]);
                Matrix3 headingTransform = new Matrix3(new double[]
                        Math.cos(heading), 0, -Math.sin(heading),
                        0, 1, 0,
                        Math.sin(heading), 0, Math.cos(heading)
                );
double pitch = Math.toRadians(y[0]);
                Matrix3 pitchTransform = new Matrix3(new double[]
                        1, 0, 0,
                        0, Math.cos(pitch), Math.sin(pitch),
                        0, -Math.sin(pitch), Math.cos(pitch)
                )
//提前进行矩阵合并
Matrix3 transform = headingTransform.multiply(pitchTransform);

然后通过监听鼠标的拖拽,改变x和y所代表的角度。

renderPanel.addMouseMotionListener(new MouseMotionListener() 
            @Override
            public void mouseDragged(MouseEvent e) 
                double yi = 180.0 / renderPanel.getHeight();
                double xi = 180.0 / renderPanel.getWidth();
                x[0] = (int) (e.getX() * xi);
                y[0] = -(int) (e.getY() * yi);
                renderPanel.repaint();
            

            @Override
            public void mouseMoved(MouseEvent e) 

            
        );

现在我们可以讲之前的四面体旋转起来了

g2.translate(getWidth() / 2, getHeight() / 2);
g2.setColor(Color.WHITE);
for (Triangle t : tris) 
    Vertex v1 = transform.transform(t.v1);
    Vertex v2 = transform.transform(t.v2);
    Vertex v3 = transform.transform(t.v3);
    Path2D path = new Path2D.Double();
    path.moveTo(v1.x, v1.y);
    path.lineTo(v2.x, v2.y);
    path.lineTo(v3.x, v3.y);
    path.closePath();
    g2.draw(path);

效果:

光栅化

现在我们需要开始用一些物质填充这些三角形。为此,我们首先需要对三角形进行“光栅化”——将其转换为屏幕上它所占据的像素列表。

光栅化(Rasterization)这一词在计算机图形学中经常出现,很多相关书籍都给出了自己的定义。不过我看目前一个比较准确的定义是:光栅化就是把东西画在屏幕上的一个过程(Rasterize == drawing onto the screen )文艺版解释:凝固生命的光栅化

光栅化中最重要的一个概念,判断一个像素与三角形之间的关系,更确却的来说我们考虑像素的中心点与三角形的位置关系。

判断一个点是否在三角形内在数学上有很多方法,本篇文章选择了叉积的方法(因为是正交投影,这样比较简单)对其他方法感兴趣的,可以根据其数学原理自己去实现一下:3D数学 | 判断点是否在三角形内

叉积

叉积的方向与两个初始向量正交,这个方向我们可以由右手螺旋定则确定。我们可以伸出右手作a向量到b向量的叉积我们可以发现叉出的方向是正朝上的(图一),而用右手螺旋定则b向量到a向量的叉积叉出的方向是正朝下的,这就是为什么a x b=-b x a。

向量的叉乘公式:

(x1,y1,z1)X(x2,y2,z2)=(y1z2-y2z1, z1x2-z2y1, x1y2-x2y1)

之前也提到了,我们可以通过叉积去判断一个点是否在三角形内,举个例子(图2):

图1

图2

三角形的方向是逆时针的,从向量AB叉到向量AP叉出来的方向是-z,说明P点在AB的左侧;从向量BC叉到向量BP叉出来的方向是- z,说明P点在BC的左侧;从向量CA叉到向量CP叉出来的方向是-z,说明P点在AC的左侧,这就说明P点在三角形的内部。因为如果不在的话那么至少存在一条边使得P点在右侧(三角形是顺时针也没有问题,P点都在三角形的右边,我们只要保证P点一直在三条边的左边或者右边就可以说它在三角形的内部)。

这里注意,因为是正交投影,所以我们只考虑在投影平面(xy面)上的像素点是否在空间三角形在该面上的投影三角形内即可,即z可视为0。

代码:

static boolean sameSide(Vertex A, Vertex B, Vertex C, Vertex p)
        Vertex V1V2 = new Vertex(B.x - A.x,B.y - A.y,B.z - A.z);
        Vertex V1V3 = new Vertex(C.x - A.x,C.y - A.y,C.z - A.z);
        Vertex V1P = new Vertex(p.x - A.x,p.y - A.y,p.z - A.z);

        //V1V2向量与V1V3的叉积如果和V1V2向量与V1p的叉积相同则在同一侧。
        //只用判断z的方向
        double V1V2CrossV1V3 = V1V2.x * V1V3.y - V1V3.x * V1V2.y;
        double V1V2CrossP = V1V2.x * V1P.y - V1P.x * V1V2.y;

        return V1V2CrossV1V3 * V1V2CrossP >= 0;
    

实现

现在我们可以知道一个点像素是否需要进行渲染了,现在要做的就是遍历范围内所有的像素点,判断它们是否需要进行渲染。

补全我们的代码:

for (Triangle t : tris) 
                    Vertex v1 = transform.transform(t.v1);
                    Vertex v2 = transform.transform(t.v2);
                    Vertex v3 = transform.transform(t.v3);
                    v1.x += getWidth() / 2.0;
                    v1.y += getHeight() / 2.0;
                    v2.x += getWidth() / 2.0;
                    v2.y += getHeight() / 2.0;
                    v3.x += getWidth() / 2.0;
                    v3.y += getHeight() / 2.0;
                    // 计算需要处理的范围
                    int minX = (int) Math.max(0, Math.ceil(Math.min(v1.x, Math.min(v2.x, v3.x))));
                    int maxX = (int) Math.min(img.getWidth() - 1,
                            Math.floor(Math.max(v1.x, Math.max(v2.x, v3.x))));
                    int minY = (int) Math.max(0, Math.ceil(Math.min(v1.y, Math.min(v2.y, v3.y))));
                    int maxY = (int) Math.min(img.getHeight() - 1,
                            Math.floor(Math.max(v1.y, Math.max(v2.y, v3.y))));

                    for (int y = minY; y < = maxY; y++) 
                        for (int x = minX; x < = maxX; x++) 
                            Vertex p = new Vertex(x,y,0);
                            //针对每个顶点判断一次
                            boolean V1 = sameSide(v1,v2,v3,p);
                            boolean V2 = sameSide(v2,v3,v1,p);
                            boolean V3 = sameSide(v3,v1,v2,p);
                            if (V3 && V2 && V1) 
                                img.setRGB(x, y, t.color.getRGB());
                            
                        
                    
                

                g2.drawImage(img, 0, 0, null);

来看看实际的效果吧!

相信你已经发现问题了:蓝色三角形总是在其他三角形之上。发生这种情况是因为我们目前正在一个接一个地绘制三角形,而蓝色三角形是最后一个 - 因此它被绘制在所有其他三角形之上。

这就引出了下一个概念:z-buffer (或深度缓冲区)的概念

z-buffer

它的作用是:在光栅化期间构建一个中间数组,该数组将存储任何给定像素处最后看到的元素的深度。光栅化三角形时,我们将检查像素深度是否小于(因为正向是-z方向)之前看到的,并且仅在像素高于其他像素时对其进行着色。

double[] zBuffer = new double[img.getWidth() * img.getHeight()];
// initialize array with extremely far away depths
for (int q = 0; q < zBuffer.length; q++) 
    zBuffer[q] = Double.NEGATIVE_INFINITY;


for (Triangle t : tris) 
    // 之前的代码
    if (V3 && V2 && V1) 
    double depth = v1.z + v2.z + v3.z;
    int zIndex = y * img.getWidth() + x;
    if (zBuffer[zIndex] < depth) 
      img.setRGB(x, y, t.color.getRGB());
      zBuffer[zIndex] = depth;
      
    

效果:

到目前为止渲染管线看起来一切正常了,但是还缺少了一个重要的效果:阴影

阴影-平面着色

在计算机图形学中的“阴影”,可以简单解释为--根据表面的角度和与灯光的距离来改变表面的颜色。

最简单的着色形式是平面着色。它只考虑表面法线和光源方向之间的角度。您只需要找到这两个向量之间的角度余弦并将颜色乘以结果值。这种方法非常简单且快速,因此当更高级的着色技术计算成本太高时,通常用它做高速渲染。

法向量

法向量,是空间解析几何的一个概念,垂直于平面的直线所表示的向量为该平面的法向量。法向量适用于解析几何。由于空间内有无数个直线垂直于已知平面,因此一个平面都存在无数个法向量(包括两个单位法向量)。

还记得之前的叉积吗,我们只需要除掉自身的模长即可得到一个法向量

Vertex ab = new Vertex(v2.x - v1.x, v2.y - v1.y, v2.z - v1.z);
    Vertex ac = new Vertex(v3.x - v1.x, v3.y - v1.y, v3.z - v1.z);
    //法向量
    Vertex norm = new Vertex(
         ab.y * ac.z - ab.z * ac.y,
         ab.z * ac.x - ab.x * ac.z,
         ab.x * ac.y - ab.y * ac.x
    );
    double normalLength =
        Math.sqrt(norm.x * norm.x + norm.y * norm.y + norm.z * norm.z);
    norm.x /= normalLength;
    norm.y /= normalLength;
    norm.z /= normalLength;

点积

点积的定义还是比较抽象的,我们只需要了解其在三维空间中的几何意义,以及公式即可。

公式:

几何意义:第一个向量投影到第二个向量上(这里,向量的顺序是不重要的,点积运算是可交换的),然后通过除以它们的标量长度来“标准化”。这样,这个分数一定是小于等于1的,可以简单地转化成一个角度值即:

光源

为了简单起见,我们使用定向光源(光直接位于相机后面无限远的距离),光源方向将是[0 0 1]。现在我们需要计算三角形法向量和光线方向之间的余弦,作为阴影的系数。

在该场景下我们可以得到:

其中A为三角形的法向量,B为光线。

化为代码非常简单:

double angleCos = Math.abs(norm.z);

为了简单处理,在这里我们不关系三角形是否面向相机,但实际上是需要根据光线追踪来判断的(下一篇光线追踪中我们再来完善它)。

现在我们的得到了阴影系数,所以可以简单的处理为:

public static Color getShade(Color color, double shade) 
    int red = (int) (color.getRed() * shade);
    int green = (int) (color.getGreen() * shade);
    int blue = (int) (color.getBlue() * shade);
    return new Color(red, green, blue);

效果:

可以看到,虽然有了阴影但是衰减的太快,这是因为Java使用的是sRGB 颜色空间,所以我们需要将每种颜色从缩放格式转换为线性格式,应用阴影,然后再转换sRGB,但是实际的转换过程非常复杂,我们只做简单的近似:

先做2.2次幂到线性空间计算阴影,然后在做1/2.2次幂回到sRGB空间

参数依据在这篇文章:Gamma、Linear、sRGB 和Unity Color Space,你真懂了吗?

现在我们来改进下代码:

public static Color getShade(Color color, double shade) 

        double redLinear = Math.pow(color.getRed(), 2.2) * shade;
        double greenLinear = Math.pow(color.getGreen(), 2.2) * shade;
        double blueLinear = Math.pow(color.getBlue(), 2.2) * shade;

        int red = (int) Math.pow(redLinear, 1 / 2.2);
        int green = (int) Math.pow(greenLinear, 1 / 2.2);
        int blue = (int) Math.pow(blueLinear, 1 / 2.2);

        return new Color(red, green, blue);
    

效果对比:

曲面

物体的平面我们可以用三角形简单的拼接进行表示,那么曲面该如何使用三角形表示呢?

一种方式是通过平面的拆分-膨胀来做到。

拆分

一个三角形可以通过三个边的中点,来拆分成4个小三角形,如下图:

通过代码可以表示为:

List< Triangle> result = new ArrayList<>();
        for (Triangle t : tris) 
                Vertex m1 =
                        new Vertex((t.v1.x + t.v2.x) / 2, (t.v1.y + t.v2.y) / 2, (t.v1.z + t.v2.z) / 2);
                Vertex m2 =
                        new Vertex((t.v2.x + t.v3.x) / 2, (t.v2.y + t.v3.y) / 2, (t.v2.z + t.v3.z) / 2);
                Vertex m3 =
                        new Vertex((t.v1.x + t.v3.x) / 2, (t.v1.y + t.v3.y) / 2, (t.v1.z + t.v3.z) / 2);
                result.add(new Triangle(t.v1, m1, m3, t.color,true));
                result.add(new Triangle(t.v2, m1, m2, t.color,true));
                result.add(new Triangle(t.v3, m2, m3, t.color,true));
                result.add(new Triangle(m1, m2, m3, t.color,true));
            
        

膨胀

现在我们获得了一些更小的三角形,现在要做的就是让它们的顶点膨胀到圆弧所在的位置上。

让我们先用二维空间的简单场景来描述这一过程:

通过上图可知:(原位置与原点的距离:L)/(三角形顶点到原点的距离:r)获得一个比例系数;然后用其当前坐标x0,y0分别除以该系数即可。

距离公式:

实际代码如下:

for (Triangle t : result) 
                for (Vertex v : new Vertex[]t.v1, t.v2, t.v3) 
                    double l = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) / Math.sqrt(30000);
                    v.x /= l;
                    v.y /= l;
                    v.z /= l;
                
        

其中3000是某一三角形顶点到原点的距离例如用(100,100,100)这个顶点为例:(100100+100100+100*100)=30000

效果

让我们先来针对一个面拆分5次然后膨胀,看下效果:

四个面全部膨胀即可得到一个圆形:

然后我们减少拆分次数(2次)看下效果:

结束收工!

参考项目:

https://gist.github.com/Rogach/f3dfd457d7ddb5fcfd99/4f2aaf20a468867dc195cdc08a02e5705c2cc95c

推荐阅读:

https://www.cnblogs.com/BigFeng/p/5006014.html
Gamma、Linear、sRGB 和Unity Color Space,你真懂了吗?Gamma、Linear、sRGB 和Unity Color Space,你真懂了吗? - 知乎

作者 | 李历成(徜葆)

原文链接

本文为阿里云原创内容,未经允许不得转载。

以上是关于跨平台多媒体渲染引擎OPR简介的主要内容,如果未能解决你的问题,请参考以下文章

HTML简介

福利解锁Part1报名参与腾讯云专场活动,第一波干货内容免费放送

浏览器渲染原理解析

reSipWebRTC

MLT 视频编辑框架简介编译与 demo 运行

跨平台视频通信项目-OpenTok