【视觉高级篇】20 # 如何用WebGL绘制3D物体?

简介: 【视觉高级篇】20 # 如何用WebGL绘制3D物体?

说明

【跟月影学可视化】学习笔记。



如何用 WebGL 绘制三维立方体

我们知道立方体有8个顶点,6个面,在 WebGL 中,需要用 12 个三角形来绘制它。把每个面的顶点分开,需要 24 个顶点。

dc5f86472e404bca8568ccae6320ee47.png


绘制 3D 图形与绘制 2D 图形有一点不一样,必须要开启深度检测和启用深度缓冲区。


在 WebGL 中,可以通过 gl.enable(gl.DEPTH_TEST),来开启深度检测。


在清空画布的时候,也要用 gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT),来同时清空颜色缓冲区和深度缓冲区。

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>如何用 WebGL 绘制三维立方体</title>
        <style>
            canvas {
                border: 1px dashed salmon;
            }
        </style>
    </head>
    <body>
        <canvas width="512" height="512"></canvas>
        <script src="./common/lib/gl-renderer.js"></script>
        <script type="module">
            const vertex = `
                attribute vec3 a_vertexPosition;
                attribute vec4 color;
                varying vec4 vColor;
                void main() {
                    gl_PointSize = 1.0;
                    vColor = color;
                    gl_Position = vec4(a_vertexPosition, 1);
                }
            `;
            const fragment = `
                #ifdef GL_ES
                precision highp float;
                #endif
                varying vec4 vColor;
                void main() {
                    gl_FragColor = vColor;
                }
            `;
            const canvas = document.querySelector("canvas");
            // 开启深度检测
            const renderer = new GlRenderer(canvas, {
                depth: true
            });
            const program = renderer.compileSync(fragment, vertex);
            renderer.useProgram(program);
            // 用来生成立方体 6 个面的 24 个顶点,以及 12 个三角形的索引
            function cube(size = 1.0, colors = [[1, 0, 0, 1]]) {
                const h = 0.5 * size;
                // 立方体的顶点
                const vertices = [
                    [-h, -h, -h],
                    [-h, h, -h],
                    [h, h, -h],
                    [h, -h, -h],
                    [-h, -h, h],
                    [-h, h, h],
                    [h, h, h],
                    [h, -h, h],
                ];
                const positions = [];
                const color = [];
                const cells = [];
                let colorIdx = 0;
                let cellsIdx = 0;
                const colorLen = colors.length;
                function quad(a, b, c, d) {
                    [a, b, c, d].forEach((i) => {
                        positions.push(vertices[i]);
                        color.push(colors[colorIdx % colorLen]);
                    });
                    cells.push(
                        [0, 1, 2].map(i => i + cellsIdx),
                        [0, 2, 3].map(i => i + cellsIdx),
                    );
                    colorIdx++;
                    cellsIdx += 4;
                }
                // 立方体的六个面
                quad(1, 0, 3, 2);
                quad(4, 5, 6, 7);
                quad(2, 3, 7, 6);
                quad(5, 4, 0, 1);
                quad(3, 0, 4, 7);
                quad(6, 5, 1, 2);
                return { positions, color, cells };
            }
            const geometry = cube(1.0, [
                [250/255, 128/255, 114/255, 1], // salmon rgb(250 128 114)
                [218/255, 165/255, 32/255, 1],// goldenrod rgb(218, 165, 32)
                [46/255, 139/255, 87/255, 1], // seagreen rgb(46 139 87)
                [255/255, 192/255, 203/255, 1], // pink rgb(255, 192, 203)
                [135/255, 206/255, 235/255, 1],// skyblue rgb(135, 206, 235)
                [106/255, 90/255, 205/255, 1], // slateblue rgb(106, 90, 205)
            ]);
            renderer.setMeshData([
                {
                    positions: geometry.positions,
                    attributes: {
                        color: geometry.color,
                    },
                    cells: geometry.cells,
                },
            ]);
            renderer.render();
        </script>
    </body>
</html>


30958450a6d04f02b61522e2ef3bd168.png


投影矩阵:变换 WebGL 坐标系


上面朝向我们的面应该是 goldenrod 颜色, WebGL 默认的剪裁坐标的 z 轴方向,的确是朝内的。WebGL 坐标系就是一个左手系而不是右手系。下面我们需要将 WebGL 的坐标系从左手系转换为右手系。


实际上就是将 z 轴坐标方向反转,对应的齐次矩阵如下:


[
  1, 0, 0, 0,
  0, 1, 0, 0,
  0, 0, -1, 0,
  0, 0, 0, 1
]
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>投影矩阵:变换 WebGL 坐标系</title>
        <style>
            canvas {
                border: 1px dashed rgb(250, 128, 114);
            }
        </style>
    </head>
    <body>
        <canvas width="512" height="512"></canvas>
        <script src="./common/lib/gl-renderer.js"></script>
        <script type="module">
            const vertex = `
                attribute vec3 a_vertexPosition;
                attribute vec4 color;
                varying vec4 vColor;
                uniform mat4 projectionMatrix;
                void main() {
                    gl_PointSize = 1.0;
                    vColor = color;
                    gl_Position = projectionMatrix * vec4(a_vertexPosition, 1);
                }
            `;
            const fragment = `
                #ifdef GL_ES
                precision highp float;
                #endif
                varying vec4 vColor;
                void main() {
                    gl_FragColor = vColor;
                }
            `;
            const canvas = document.querySelector("canvas");
            // 开启深度检测
            const renderer = new GlRenderer(canvas, {
                depth: true
            });
            const program = renderer.compileSync(fragment, vertex);
            renderer.useProgram(program);
            // 用来生成立方体 6 个面的 24 个顶点,以及 12 个三角形的索引
            function cube(size = 1.0, colors = [[1, 0, 0, 1]]) {
                const h = 0.5 * size;
                // 立方体的顶点
                const vertices = [
                    [-h, -h, -h],
                    [-h, h, -h],
                    [h, h, -h],
                    [h, -h, -h],
                    [-h, -h, h],
                    [-h, h, h],
                    [h, h, h],
                    [h, -h, h],
                ];
                const positions = [];
                const color = [];
                const cells = [];
                let colorIdx = 0;
                let cellsIdx = 0;
                const colorLen = colors.length;
                function quad(a, b, c, d) {
                    [a, b, c, d].forEach((i) => {
                        positions.push(vertices[i]);
                        color.push(colors[colorIdx % colorLen]);
                    });
                    cells.push(
                        [0, 1, 2].map(i => i + cellsIdx),
                        [0, 2, 3].map(i => i + cellsIdx),
                    );
                    colorIdx++;
                    cellsIdx += 4;
                }
                // 立方体的六个面
                quad(1, 0, 3, 2);
                quad(4, 5, 6, 7);
                quad(2, 3, 7, 6);
                quad(5, 4, 0, 1);
                quad(3, 0, 4, 7);
                quad(6, 5, 1, 2);
                return { positions, color, cells };
            }
            const geometry = cube(1.0, [
                [250/255, 128/255, 114/255, 1], // salmon rgb(250 128 114)
                [218/255, 165/255, 32/255, 1],// goldenrod rgb(218, 165, 32)
                [46/255, 139/255, 87/255, 1], // seagreen rgb(46 139 87)
                [255/255, 192/255, 203/255, 1], // pink rgb(255, 192, 203)
                [135/255, 206/255, 235/255, 1],// skyblue rgb(135, 206, 235)
                [106/255, 90/255, 205/255, 1], // slateblue rgb(106, 90, 205)
            ]);
            // 将 z 轴坐标方向反转,对应的齐次矩阵如下,转换坐标的齐次矩阵,又被称为投影矩阵(ProjectionMatrix)
            renderer.uniforms.projectionMatrix = [
                1, 0, 0, 0,
                0, 1, 0, 0,
                0, 0, -1, 0,
                0, 0, 0, 1,
            ];
            renderer.setMeshData([
                {
                    positions: geometry.positions,
                    attributes: {
                        color: geometry.color,
                    },
                    cells: geometry.cells,
                },
            ]);
            renderer.render();
        </script>
    </body>
</html>

d4f293fec9ab4114a5d2fe1ec090057e.png


模型矩阵:让立方体旋转起来

用立方体沿 x、y、z 轴的旋转来生成模型矩阵。以 x、y、z 三个方向的旋转得到三个齐次矩阵,然后将它们相乘,就能得到最终的模型矩阵。

image.gif



如何用 WebGL 绘制圆柱体

圆柱体的两个底面都是圆,可以用割圆的方式对圆进行简单的三角剖分,然后把圆柱的侧面用上下两个圆上的顶点进行三角剖分。


24bbdef1444a42528ccb81766886e882.png

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>如何用 WebGL 绘制圆柱体</title>
        <style>
            canvas {
                border: 1px dashed rgb(250, 128, 114);
            }
        </style>
    </head>
    <body>
        <canvas width="512" height="512"></canvas>
        <script src="./common/lib/gl-renderer.js"></script>
        <script type="module">
            import { multiply } from './common/lib/math/functions/Mat4Func.js';
            const vertex = `
                attribute vec3 a_vertexPosition;
                attribute vec4 color;
                varying vec4 vColor;
                uniform mat4 projectionMatrix;
                uniform mat4 modelMatrix;
                void main() {
                    gl_PointSize = 1.0;
                    vColor = color;
                    gl_Position = projectionMatrix * modelMatrix * vec4(a_vertexPosition, 1.0);
                }
            `;
            const fragment = `
                #ifdef GL_ES
                precision highp float;
                #endif
                varying vec4 vColor;
                void main() {
                    gl_FragColor = vColor;
                }
            `;
            const canvas = document.querySelector("canvas");
            // 开启深度检测
            const renderer = new GlRenderer(canvas, {
                depth: true
            });
            const program = renderer.compileSync(fragment, vertex);
            renderer.useProgram(program);
            function cylinder(radius = 1.0, height = 1.0, segments = 30, colorCap = [0, 0, 1, 1], colorSide = [1, 0, 0, 1]) {
                const positions = [];
                const cells = [];
                const color = [];
                const cap = [[0, 0]];
                const h = 0.5 * height;
                // 顶和底的圆
                for(let i = 0; i <= segments; i++) {
                    const theta = Math.PI * 2 * i / segments;
                    const p = [radius * Math.cos(theta), radius * Math.sin(theta)];
                    cap.push(p);
                }
                positions.push(...cap.map(([x, y]) => [x, y, -h]));
                for(let i = 1; i < cap.length - 1; i++) {
                    cells.push([0, i, i + 1]);
                }
                cells.push([0, cap.length - 1, 1]);
                let offset = positions.length;
                positions.push(...cap.map(([x, y]) => [x, y, h]));
                for(let i = 1; i < cap.length - 1; i++) {
                    cells.push([offset, offset + i, offset + i + 1]);
                }
                cells.push([offset, offset + cap.length - 1, offset + 1]);
                color.push(...positions.map(() => colorCap));
                // 侧面
                offset = positions.length;
                for(let i = 1; i < cap.length; i++) {
                    const a = [...cap[i], h];
                    const b = [...cap[i], -h];
                    const nextIdx = i < cap.length - 1 ? i + 1 : 1;
                    const c = [...cap[nextIdx], -h];
                    const d = [...cap[nextIdx], h];
                    positions.push(a, b, c, d);
                    color.push(colorSide, colorSide, colorSide, colorSide);
                    cells.push([offset, offset + 1, offset + 2], [offset, offset + 2, offset + 3]);
                    offset += 4;
                }
                return { positions, cells, color };
            }
            const geometry = cylinder(0.2, 1.0, 400,
                [250/255, 128/255, 114/255, 1], // salmon rgb(250 128 114)
                [46/255, 139/255, 87/255, 1], // seagreen rgb(46 139 87)
            );
            // 将 z 轴坐标方向反转,对应的齐次矩阵如下,转换坐标的齐次矩阵,又被称为投影矩阵(ProjectionMatrix)
            renderer.uniforms.projectionMatrix = [
                1, 0, 0, 0,
                0, 1, 0, 0,
                0, 0, -1, 0,
                0, 0, 0, 1,
            ];
            function fromRotation(rotationX, rotationY, rotationZ) {
                let c = Math.cos(rotationX);
                let s = Math.sin(rotationX);
                const rx = [
                    1, 0, 0, 0,
                    0, c, s, 0,
                    0, -s, c, 0,
                    0, 0, 0, 1,
                ];
                c = Math.cos(rotationY);
                s = Math.sin(rotationY);
                const ry = [
                    c, 0, s, 0,
                    0, 1, 0, 0,
                    -s, 0, c, 0,
                    0, 0, 0, 1,
                ];
                c = Math.cos(rotationZ);
                s = Math.sin(rotationZ);
                const rz = [
                    c, s, 0, 0,
                    -s, c, 0, 0,
                    0, 0, 1, 0,
                    0, 0, 0, 1,
                ];
                const ret = [];
                multiply(ret, rx, ry);
                multiply(ret, ret, rz);
                return ret;
            }
            renderer.setMeshData([
                {
                    positions: geometry.positions,
                    attributes: {
                        color: geometry.color,
                    },
                    cells: geometry.cells,
                },
            ]);
            let rotationX = 0;
            let rotationY = 0;
            let rotationZ = 0;
            function update() {
                rotationX += 0.003;
                rotationY += 0.005;
                rotationZ += 0.007;
                renderer.uniforms.modelMatrix = fromRotation(rotationX, rotationY, rotationZ);
                requestAnimationFrame(update);
            }
            update();
            renderer.render();
        </script>
    </body>
</html>

183f6e038b3342e59c8d635faa52fbf3.gif



使用法向量和法向量矩阵来实现点光源光照效果


对于圆柱体来说,底面和顶面法线分别是 (0, 0, -1) 和 (0, 0, 1),侧面的法向量可以通过三角网格来计算。


因为几何体是由三角网格构成的,而法线是垂直于三角网格的线,如果要计算法线,我们可以借助三角形的顶点,使用向量的叉积定理来求。假设在一个平面内,有向量 a 和 b,n 是它们的法向量,那我们可以得到公式:n = a X b。


728c4f52d3c746a39571e4c3940669b6.png

在片元着色器中,拿到的是变换后的顶点坐标,需要对法向量也进行变换,可以通过一个矩阵来实现,这个矩阵叫做法向量矩阵(NormalMatrix)。

在顶点着色器中,计算位于(1,0,0)坐标处的点光源与几何体法线的夹角余弦。根据物体漫反射模型,光照强度等于光线与法向量夹角的余弦,就能在片元着色器叠加光照。


6449220c7f97417da0f90df59321ac0b.png

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>使用法向量和法向量矩阵来实现点光源光照效果</title>
        <style>
            canvas {
                border: 1px dashed rgb(250, 128, 114);
            }
        </style>
    </head>
    <body>
        <canvas width="512" height="512"></canvas>
        <script src="./common/lib/gl-renderer.js"></script>
        <script type="module">
            import { multiply } from './common/lib/math/functions/Mat4Func.js';
            import { cross, subtract, normalize } from './common/lib/math/functions/Vec3Func.js';
            import { normalFromMat4 } from './common/lib/math/functions/Mat3Func.js';
            const vertex = `
                attribute vec3 a_vertexPosition;
                attribute vec4 color;
                attribute vec3 normal;
                varying vec4 vColor;
                varying float vCos;
                uniform mat4 projectionMatrix;
                uniform mat4 modelMatrix;
                uniform mat3 normalMatrix;
                const vec3 lightPosition = vec3(1, 0, 0);
                void main() {
                    gl_PointSize = 1.0;
                    vColor = color;
                    vec4 pos =  modelMatrix * vec4(a_vertexPosition, 1.0);
                    vec3 invLight = lightPosition - pos.xyz;
                    vec3 norm = normalize(normalMatrix * normal);
                    vCos = max(dot(normalize(invLight), norm), 0.0);
                    gl_Position = projectionMatrix * pos;
                }
            `;
            const fragment = `
                #ifdef GL_ES
                precision highp float;
                #endif
                uniform vec4 lightColor;
                varying vec4 vColor;
                varying float vCos;
                void main() {
                    gl_FragColor.rgb = vColor.rgb + vCos * lightColor.a * lightColor.rgb;
                    gl_FragColor.a = vColor.a;
                }
            `;
            const canvas = document.querySelector("canvas");
            // 开启深度检测
            const renderer = new GlRenderer(canvas, {
                depth: true
            });
            const program = renderer.compileSync(fragment, vertex);
            renderer.useProgram(program);
            function cylinder(radius = 1.0, height = 1.0, segments = 30, colorCap = [0, 0, 1, 1], colorSide = [1, 0, 0, 1]) {
                const positions = [];
                const cells = [];
                const color = [];
                const cap = [[0, 0]];
                const h = 0.5 * height;
                const normal = [];
                // 顶和底的圆
                for(let i = 0; i <= segments; i++) {
                    const theta = Math.PI * 2 * i / segments;
                    const p = [radius * Math.cos(theta), radius * Math.sin(theta)];
                    cap.push(p);
                }
                positions.push(...cap.map(([x, y]) => [x, y, -h]));
                normal.push(...cap.map(() => [0, 0, -1]));
                for(let i = 1; i < cap.length - 1; i++) {
                    cells.push([0, i, i + 1]);
                }
                cells.push([0, cap.length - 1, 1]);
                let offset = positions.length;
                positions.push(...cap.map(([x, y]) => [x, y, h]));
                normal.push(...cap.map(() => [0, 0, 1]));
                for(let i = 1; i < cap.length - 1; i++) {
                    cells.push([offset, offset + i, offset + i + 1]);
                }
                cells.push([offset, offset + cap.length - 1, offset + 1]);
                color.push(...positions.map(() => colorCap));
                const tmp1 = [];
                const tmp2 = [];
                // 侧面,这里需要求出侧面的法向量
                offset = positions.length;
                for(let i = 1; i < cap.length; i++) {
                    const a = [...cap[i], h];
                    const b = [...cap[i], -h];
                    const nextIdx = i < cap.length - 1 ? i + 1 : 1;
                    const c = [...cap[nextIdx], -h];
                    const d = [...cap[nextIdx], h];
                    positions.push(a, b, c, d);
                    const norm = [];
                    cross(norm, subtract(tmp1, b, a), subtract(tmp2, c, a));
                    normalize(norm, norm);
                    normal.push(norm, norm, norm, norm); // abcd四个点共面,它们的法向量相同
                    color.push(colorSide, colorSide, colorSide, colorSide);
                    cells.push([offset, offset + 1, offset + 2], [offset, offset + 2, offset + 3]);
                    offset += 4;
                }
                return { positions, cells, color, normal };
            }
            const geometry = cylinder(0.2, 1.0, 400,
                [250/255, 128/255, 114/255, 1], // salmon rgb(250 128 114)
                [46/255, 139/255, 87/255, 1], // seagreen rgb(46 139 87)
            );
            // 将 z 轴坐标方向反转,对应的齐次矩阵如下,转换坐标的齐次矩阵,又被称为投影矩阵(ProjectionMatrix)
            renderer.uniforms.projectionMatrix = [
                1, 0, 0, 0,
                0, 1, 0, 0,
                0, 0, -1, 0,
                0, 0, 0, 1,
            ];
            renderer.uniforms.lightColor = [218/255, 165/255, 32/255, 0.6];// goldenrod rgb(218, 165, 32)
            function fromRotation(rotationX, rotationY, rotationZ) {
                let c = Math.cos(rotationX);
                let s = Math.sin(rotationX);
                const rx = [
                    1, 0, 0, 0,
                    0, c, s, 0,
                    0, -s, c, 0,
                    0, 0, 0, 1,
                ];
                c = Math.cos(rotationY);
                s = Math.sin(rotationY);
                const ry = [
                    c, 0, s, 0,
                    0, 1, 0, 0,
                    -s, 0, c, 0,
                    0, 0, 0, 1,
                ];
                c = Math.cos(rotationZ);
                s = Math.sin(rotationZ);
                const rz = [
                    c, s, 0, 0,
                    -s, c, 0, 0,
                    0, 0, 1, 0,
                    0, 0, 0, 1,
                ];
                const ret = [];
                multiply(ret, rx, ry);
                multiply(ret, ret, rz);
                return ret;
            }
            console.log(geometry);
            renderer.setMeshData([
                {
                    positions: geometry.positions,
                    attributes: {
                        color: geometry.color,
                        normal: geometry.normal
                    },
                    cells: geometry.cells,
                },
            ]);
            let rotationX = 0;
            let rotationY = 0;
            let rotationZ = 0;
            function update() {
                rotationX += 0.003;
                rotationY += 0.005;
                rotationZ += 0.007;
                const modelMatrix = fromRotation(rotationX, rotationY, rotationZ);
                renderer.uniforms.modelMatrix = modelMatrix;
                renderer.uniforms.normalMatrix = normalFromMat4([], modelMatrix);
                requestAnimationFrame(update);
            }
            update();
            renderer.render();
        </script>
    </body>
</html>

ecb7777b516a47f087b332eee6447d95.gif


目录
相关文章
|
6月前
|
前端开发 JavaScript 数据可视化
WebGL 入门:开启三维网页图形的新篇章(上)
WebGL 入门:开启三维网页图形的新篇章(上)
|
6月前
|
前端开发 API vr&ar
Android开发之OpenGL绘制三维图形的流程
即将连载的系列文章将探索Android上的OpenGL开发,这是一种用于创建3D图形和动画的技术。OpenGL是跨平台的图形库,Android已集成其API。文章以2D绘图为例,解释了OpenGL的3个核心元素:GLSurfaceView(对应View)、GLSurfaceView.Renderer(类似Canvas)和GL10(类似Paint)。通过将这些结合,Android能实现3D图形渲染。文章介绍了Renderer接口的三个方法,分别对应2D绘图的构造、测量布局和绘制过程。示例代码展示了如何在布局中添加GLSurfaceView并注册渲染器。
197 1
Android开发之OpenGL绘制三维图形的流程
|
6月前
|
存储 缓存 数据可视化
WebGL 入门:开启三维网页图形的新篇章(下)
WebGL 入门:开启三维网页图形的新篇章(下)
WebGL 入门:开启三维网页图形的新篇章(下)
|
人工智能
像相机一样变焦、填充画面细节,还能自定义风格,AI作画神器Midjourney又更新了
像相机一样变焦、填充画面细节,还能自定义风格,AI作画神器Midjourney又更新了
178 1
【视觉高级篇】19 # 如何用着色器实现像素动画?2
【视觉高级篇】19 # 如何用着色器实现像素动画?
87 0
【视觉高级篇】19 # 如何用着色器实现像素动画?2
|
数据可视化 异构计算
【视觉高级篇】19 # 如何用着色器实现像素动画?
【视觉高级篇】19 # 如何用着色器实现像素动画?
100 0
【视觉高级篇】19 # 如何用着色器实现像素动画?
|
数据可视化
【视觉基础篇】15 # 如何用极坐标系绘制有趣图案?
【视觉基础篇】15 # 如何用极坐标系绘制有趣图案?
166 0
【视觉基础篇】15 # 如何用极坐标系绘制有趣图案?
|
缓存 JavaScript 前端开发
【图形基础篇】04 # GPU与渲染管线:如何用WebGL绘制最简单的几何图形?
【图形基础篇】04 # GPU与渲染管线:如何用WebGL绘制最简单的几何图形?
375 0
【图形基础篇】04 # GPU与渲染管线:如何用WebGL绘制最简单的几何图形?
|
传感器 JSON 数据可视化
【视觉高级篇】22 # 如何用仿射变换来移动和旋转3D物体?
【视觉高级篇】22 # 如何用仿射变换来移动和旋转3D物体?
196 0
【视觉高级篇】22 # 如何用仿射变换来移动和旋转3D物体?
|
算法 前端开发 JavaScript
【视觉基础篇】10 # 图形系统如何表示颜色?
【视觉基础篇】10 # 图形系统如何表示颜色?
172 0
【视觉基础篇】10 # 图形系统如何表示颜色?