不用任何现成技术,纯原生JS徒手写一个“3D引擎”

1,527 阅读5分钟

前言

注意看:

这是一个正方体,但是没有使用到任何 WebGL 与 Canvas 点线面相关的 API ,以及各种第三方库和插件。可以在代码中看到,全程都是徒手通过一个最简单最基础的光栅算法,生成像素点与颜色信息,只用到了 Canvas 的 createImageData 与 putImageData 方法,将像素点渲染到屏幕上。

原理

本文的代码和内容就是一次简单地对计算机图形学原理的学习,核心内容就是渲染原理:光栅化。除此之外,本文代码中也有一个重要的知识点,那就是基于面向对象去开发一个引擎的设计思维

本文不讲矩阵,不讲算法,因为网上太多相关知识了。作者仅做一个归纳总结,聊聊大家能够快速理解的,以便让大家通俗易懂地去了解一个图形是怎么渲染到屏幕上的,以及一个 3D 引擎的设计方法。

本文代码已做详细注释,直接在上面码上掘金即可获取,需要学习算法的可以直接看代码。

目前本文代码仅在码上掘金中以一个小 demo 的形式提供,后续我会继续完善,添加裁剪平面、深度缓冲区、贴图、光线、阴影等功能,使用 TypeScript 工程化的方式写一个相对完整的 3D 引擎

光栅化

光栅化主要完成以下两个功能:

  • 将几何图元(三角形/多边形)通过透视投影,投影到屏幕上
  • 将投影之后的图元分解成片段

透视投影,即将一个三维物体投影到二维平面上:

png

图元转换为像素,即将投影后的矢量图形转为屏幕上的像素点:

png

png

在本文代码中,在class Rasterizer类中可以看到有个rasterize()方法:

    // 光栅渲染器的入口
    rasterize() {
        // 变换三角形的顶点
        this.transformVertices();
        // 提前终止对隐藏面的渲染
        if (this.testHidden()) {
            return;
        }
        // 将三角形转换为扫描线
        this.scanTriangle();
        // 给三角形的像素着色
        this.renderTriangle();
    }
  • 变换三角形的顶点,即为处理透视投影
  • 提前终止对隐藏面的渲染:若当前视角中不存在该图形了,就不进行渲染处理
  • 将三角形转换为扫描线,即为将投影之后的图元分解成片段
  • 给三角形的像素着色,即将图元转换为的像素渲染到屏幕上

光栅化主要就做这事情,具体算法可以看详细代码。

引擎设计

使用面向对象的方法,我们来分析显示这一个正方体需要用到哪些对象:

  • ContainerScreen:用于渲染该图形的容器
  • Vector3D:一个空间向量类,用于定义该正方体的顶点信息,以及其它方向矢量
  • Camera:当前视角
  • Loop:渲染循环,控制帧数
  • Rasterizer:光栅

我们需要控制视角,移动方向,于是就需要接入键盘事件相关功能:

  • Keyboard:负责键盘按键监听
  • Input:负责输入控制的统一管理,在此注册键盘、鼠标、触控等相关监听
  • Logic:负责输入等交互事件的具体实现

最后用一个引擎类去管理上述内容:

  • Engine:引擎主入口

引擎

对于大多数开发者来说,我们并不需要特别去精通计算机底层,更多的是面向业务开发,因此在我看来,掌握一个优秀的代码设计思维是必不可少的。所以通过本文了解光栅渲染的基本原理即可,但是我希望大家能够掌握这一个简单的基于面向对象的引擎设计方法,让自己在未来能够写出更优秀的代码。

class Engine {

        screen;
        logic;
        camera;
        cube;
        input;
        loop;

        constructor(screen, logic) {
            this.screen = screen;
            this.logic = logic;
            // 实例化摄像机
            this.camera = new Camera();
            // 实例化正方体
            this.cube = new Cube(this.screen, this.camera);
            // 实例化键盘
            this.input = new Input();
            this.input.addInteraction(() => {
                this.logic.doCameraInteraction(this.camera, this.input.getKeyboard());
            });
            // 实例化循环
            this.loop = new Loop(screen, () => this.render());
        }

        startLoop() {
            this.loop.start();
        }

        stopLoop() {
            this.loop.stop();
        }

        startInteraction() {
            this.input.start();
        }

        stopInteraction() {
            this.input.stop();
        }

        render() {
            // 把背景渲染成天蓝色
            this.screen.setBackgroundRGBA(163, 216, 239, 255);
            // 渲染正方体
            this.cube.render();
        }

    }

这是Engine类的具体代码,在实例化Engine时,它自身就会去进行摄像机、正方体、键盘监听、循环方法等一系列的实例化操作,这也是面向对象自身三大特点之一的封装的优势。

引擎将所有内容都进行了一个封装管理,于是我们在使用引擎的时候,只需要传入所需渲染的屏幕容器交互事件的具体实现即可:

    const canvas = document.getElementById('canvas');
    const screen = new ContainerScreen(canvas, 1280, 800);
    const logic = new Logic();

    // 实例化引擎,开始循环与按键事件
    const engine = new Engine(screen, logic);
    engine.startLoop();
    engine.startInteraction();

总结

本文内容说难也难,难在光栅算法与计算机图形学的相关概念。说简单,它也简单,你可以只简单了解下图形学,着重在代码中学学面向对象的思维与引擎设计。算法和概念是死的,设计思维是活的,其实,掌握一个优秀的设计思维,灵活应用在日常工作项目中,写好每一行代码,做好每一块业务,高效开发,不写屎山,才是我们前端人更应该去追求的东西

本文代码在文章开头的码上掘金即可获取。