平面着色
概念
为了能够应用平面着色算法,我们首先需要计算面的法线向量。我们一旦得到了它,我们还需要知道该法线向量和光向量之间的角度。为了更精确,我们将使用 点积 返回给我们两个向量之间角的余弦。
因为这样的值可能是-1 和 1 之间的数,我们将它们收紧到 0-1 之间。我们的面根据最终的光量值来计算颜色。总之,我们的面最终颜色将是 = color * Math.Max(0, cos(angle))。
让我们从法线向量开始。维基百科定义 法线(几何体) 指出:“对于 凸 多边形 (如 三角形 ),一个表面法线可被计算为多边形两(非平行)边向量的 叉积 ”。
为了说明这一点,你可以在 Blender 文档中看到一个有趣的内容: Blender 3D:入门到精通 - Normal_coordinates
蓝色箭头是面的法线,绿色和红色箭头可能是面的任何边缘向量。让我们用 Blender 的苏珊妮模型来了解这些法线向量。
打开 Blender,加载苏珊妮网格,切换到“编辑模式”:
通过点击它,然后按下“N”键打开网格的属性。在“显示网格”中,你能找到 2 个法线相关按钮。点击“显示面的法线”:
你将会得到类似这样的效果:
我们之后将会定义一个光。这些光将成为教程中最简单的一个:一个点光源。这个点光源是简单的 3D 点(Vector3 类型)。无论距离如何,我们的面接受光的数量是相同的。然后,我们将会简单的基于法线向量和光点向量的角度以及我们的面的中心来改变光的强度。
因此,光的方向将是:光的位置 - 面的中心位置 -> 这将会给我们光的方向向量。为了计算光向量和法线向量之间的角度,我们将使用点积: http://en.wikipedia.org/wiki/Dot_product
该图来自: 逐像素光照 (由 John Chapman 撰写的文章)
代码
一般情况下,我们将首先需要计算法线向量。幸运的是,Blender 将为我们计算这些法线向量。更妙的是,它输出的每个顶点的法线,我们将在第二部分使用。因此,要计算我们的法线向量,我们只需要取 3 个顶点的法线向量,将他们累加后除以 3。
我们需要重构一下以前的代码,一遍能够处理这些新的概念。到现在为止,我们只用到了 Vector3 类型的顶点数组。这已经不够了。我们还需要更多的数据:与顶点相关的法线(对于高氏着色而言)以及 3D 投影坐标。实际上,当前投影只在 2D 完成。我们需要保持 3D 坐标投影才能够算出 3D 世界中的各种向量。
然后,我们将创建一个包含 3 个 Vector3 类型的结构:法线向量到顶点以及世界坐标,这些坐标是我们目前一直在使用的。
这个 ProcessScanLine 方法必须进行插值更多的数据(比如高氏着色中每个顶点的法线)。因此,我们将创建一个ScanLineData结构。
【译者注:C#代码】
public class Mesh
{
public string Name { get; set; }
public Vertex[] Vertices { get; private set; }
public Face[] Faces { get; set; }
public Vector3 Position { get; set; }
public Vector3 Rotation { get; set; }
public Mesh(string name, int verticesCount, int facesCount)
{
Vertices = new Vertex[verticesCount];
Faces = new Face[facesCount];
Name = name;
}
}
public struct Vertex
{
public Vector3 Normal;
public Vector3 Coordinates;
public Vector3 WorldCoordinates;
}
public struct ScanLineData
{
public int currentY;
public float ndotla;
public float ndotlb;
public float ndotlc;
public float ndotld;
}
【译者注:TypeScript 代码】
export interface Vertex {
Normal: BABYLON.Vector3;
Coordinates: BABYLON.Vector3;
WorldCoordinates: BABYLON.Vector3;
}
export class Mesh {
Position: BABYLON.Vector3;
Rotation: BABYLON.Vector3;
Vertices: Vertex[];
Faces: Face[];
constructor(public name: string, verticesCount: number, facesCount: number) {
this.Vertices = new Array(verticesCount);
this.Faces = new Array(facesCount);
this.Rotation = new BABYLON.Vector3(0, 0, 0);
this.Position = new BABYLON.Vector3(0, 0, 0);
}
}
export interface ScanLineData {
currentY?: number;
ndotla?: number;
ndotlb?: number;
ndotlc?: number;
ndotld?: number;
}
JavaScript 代码与之前教程中的代码没有变化,因此我们不用改变什么。除了进行结构修改。第一种是通过 Blender 导出的 Json 文件,我们需要加载的每个顶点的法线以及建立顶点对象,而不是顶点数组中的 Vector3 类型的对象:
【译者注:C#代码】
// 首先填充我们网格的顶点数组
for (var index = 0; index < verticesCount; index++)
{
var x = (float)verticesArray[index * verticesStep].Value;
var y = (float)verticesArray[index * verticesStep + 1].Value;
var z = (float)verticesArray[index * verticesStep + 2].Value;
// 加载 Blender 导出的顶点法线
var nx = (float)verticesArray[index * verticesStep + 3].Value;
var ny = (float)verticesArray[index * verticesStep + 4].Value;
var nz = (float)verticesArray[index * verticesStep + 5].Value;
mesh.Vertices[index] = new Vertex{ Coordinates= new Vector3(x, y, z), Normal= new Vector3(nx, ny, nz) };
}
【译者注:TypeScript 代码】
// 首先填充我们网格的顶点数组
for (var index = 0; index < verticesCount; index++) {
var x = verticesArray[index * verticesStep];
var y = verticesArray[index * verticesStep + 1];
var z = verticesArray[index * verticesStep + 2];
// 加载 Blender 导出的顶点法线
var nx = verticesArray[index * verticesStep + 3];
var ny = verticesArray[index * verticesStep + 4];
var nz = verticesArray[index * verticesStep + 5];
mesh.Vertices[index] = {
Coordinates: new BABYLON.Vector3(x, y, z),
Normal: new BABYLON.Vector3(nx, ny, nz),
WorldCoordinates: null
};
}
【译者注:JavaScript 代码】
// 首先填充我们网格的顶点数组
for (var index = 0; index < verticesCount; index++) {
var x = verticesArray[index * verticesStep];
var y = verticesArray[index * verticesStep + 1];
var z = verticesArray[index * verticesStep + 2];
// 加载 Blender 导出的顶点法线
var nx = verticesArray[index * verticesStep + 3];
var ny = verticesArray[index * verticesStep + 4];
var nz = verticesArray[index * verticesStep + 5];
mesh.Vertices[index] = {
Coordinates: new BABYLON.Vector3(x, y, z),
Normal: new BABYLON.Vector3(nx, ny, nz),
WorldCoordinates: null
};
}
这里是所有已更新的方法/功能:
- -Project() 在正在工作的顶点结构中,投射(使用世界矩阵)顶点的三维坐标,使得每个顶点被正常投射。
- -DrawTriangle() 输入一些顶点结构,调用 NDotL 与 ComputeNDotL 算出结果,然后用这些数据调用 ProcessScanLine 函数。
- -ComputeNDotL() 计算法线和光的方向之间角度的余弦。
- -ProcessScanLine() 使用 NDotL 值改变颜色并发送到 DrawTriangle。我们目前每个三角形只有 1 种颜色,因为我们使用的是平面渲染。
如果你已经对之前的教程消化完毕并且理解了本章开头的概念,那么你只需要阅读下面的代码就能知道有哪些改变:
【译者注:C#代码】
// 将三维坐标和变换矩阵转换成二维坐标
public Vertex Project(Vertex vertex, Matrix transMat, Matrix world)
{
// 将坐标转换为二维空间
var point2d = Vector3.TransformCoordinate(vertex.Coordinates, transMat);
// 在三维世界中转换坐标和法线的顶点
var point3dWorld = Vector3.TransformCoordinate(vertex.Coordinates, world);
var normal3dWorld = Vector3.TransformCoordinate(vertex.Normal, world);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point2d.X * renderWidth + renderWidth / 2.0f;
var y = -point2d.Y * renderHeight + renderHeight / 2.0f;
return new Vertex
{
Coordinates = new Vector3(x, y, point2d.Z),
Normal = normal3dWorld,
WorldCoordinates = point3dWorld
};
}
// 在两点之间从左到右绘制一条线段
// papb -> pcpd
// pa, pb, pc, pd 在之前必须已经排好序
void ProcessScanLine(ScanLineData data, Vertex va, Vertex vb, Vertex vc, Vertex vd, Color4 color)
{
Vector3 pa = va.Coordinates;
Vector3 pb = vb.Coordinates;
Vector3 pc = vc.Coordinates;
Vector3 pd = vd.Coordinates;
// 由当前的 y 值,我们可以计算出梯度
// 以此再计算出 起始 X(sx) 和 结束 X(ex)
// 如果 pa.Y == pb.Y 或者 pc.Y== pd.y 的话,梯度强制为 1
var gradient1 = pa.Y != pb.Y ? (data.currentY - pa.Y) / (pb.Y - pa.Y) : 1;
var gradient2 = pc.Y != pd.Y ? (data.currentY - pc.Y) / (pd.Y - pc.Y) : 1;
int sx = (int)Interpolate(pa.X, pb.X, gradient1);
int ex = (int)Interpolate(pc.X, pd.X, gradient2);
// 开始 Z 值和结束 Z 值
float z1 = Interpolate(pa.Z, pb.Z, gradient1);
float z2 = Interpolate(pc.Z, pd.Z, gradient2);
// 从左(sx) 向右(ex) 绘制一条线
for (var x = sx; x < ex; x++)
{
float gradient = (x - sx) / (float)(ex - sx);
var z = Interpolate(z1, z2, gradient);
var ndotl = data.ndotla;
// 基于光向量和法线向量之间角度的余弦改变颜色值
DrawPoint(new Vector3(x, data.currentY, z), color * ndotl);
}
}
// 计算光向量和法线向量之间角度的余弦
// 返回 0 到 1 之间的值
float ComputeNDotL(Vector3 vertex, Vector3 normal, Vector3 lightPosition)
{
var lightDirection = lightPosition - vertex;
normal.Normalize();
lightDirection.Normalize();
return Math.Max(0, Vector3.Dot(normal, lightDirection));
}
public void DrawTriangle(Vertex v1, Vertex v2, Vertex v3, Color4 color)
{
// 进行排序,p1 总在最上面,p2 总在最中间,p3 总在最下面
if (v1.Coordinates.Y > v2.Coordinates.Y)
{
var temp = v2;
v2 = v1;
v1 = temp;
}
if (v2.Coordinates.Y > v3.Coordinates.Y)
{
var temp = v2;
v2 = v3;
v3 = temp;
}
if (v1.Coordinates.Y > v2.Coordinates.Y)
{
var temp = v2;
v2 = v1;
v1 = temp;
}
Vector3 p1 = v1.Coordinates;
Vector3 p2 = v2.Coordinates;
Vector3 p3 = v3.Coordinates;
// 法线面上的向量是该法线面和每个顶点法线面中心点的平均值
Vector3 vnFace = (v1.Normal + v2.Normal + v3.Normal) / 3;
Vector3 centerPoint = (v1.WorldCoordinates + v2.WorldCoordinates + v3.WorldCoordinates) / 3;
// 光照位置
Vector3 lightPos = new Vector3(0, 10, 10);
// 计算光向量和法线向量之间夹角的余弦
// 它会返回介于 0 和 1 之间的值,该值将被用作颜色的亮度
float ndotl = ComputeNDotL(centerPoint, vnFace, lightPos);
var data = new ScanLineData { ndotla = ndotl };
// 计算线条的方向
float dP1P2, dP1P3;
// http://en.wikipedia.org/wiki/Slope
// 计算斜率
if (p2.Y - p1.Y > 0)
dP1P2 = (p2.X - p1.X) / (p2.Y - p1.Y);
else
dP1P2 = 0;
if (p3.Y - p1.Y > 0)
dP1P3 = (p3.X - p1.X) / (p3.Y - p1.Y);
else
dP1P3 = 0;
// 在第一种情况下,三角形是这样的:
// P1
// -
// --
// - -
// - -
// - - P2
// - -
// - -
// -
// P3
if (dP1P2 > dP1P3)
{
for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
{
data.currentY = y;
if (y < p2.Y)
{
ProcessScanLine(data, v1, v3, v1, v2, color);
}
else
{
ProcessScanLine(data, v1, v3, v2, v3, color);
}
}
}
// 在第二种情况下,三角形是这样的:
// P1
// -
// --
// - -
// - -
// P2 - -
// - -
// - -
// -
// P3
else
{
for (var y = (int)p1.Y; y <= (int)p3.Y; y++)
{
data.currentY = y;
if (y < p2.Y)
{
ProcessScanLine(data, v1, v2, v1, v3, color);
}
else
{
ProcessScanLine(data, v2, v3, v1, v3, color);
}
}
}
}
【译者注:TypeScript 代码】
// 将三维坐标和变换矩阵转换成二维坐标
public project(vertex: Vertex,
transMat: BABYLON.Matrix,
world: BABYLON.Matrix): Vertex {
// 将坐标转换为二维空间
var point2d = BABYLON.Vector3.TransformCoordinates(vertex.Coordinates, transMat);
// 在三维世界中转换坐标和法线的顶点
var point3DWorld = BABYLON.Vector3.TransformCoordinates(vertex.Coordinates, world);
var normal3DWorld = BABYLON.Vector3.TransformCoordinates(vertex.Normal, world);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point2d.x * this.workingWidth + this.workingWidth / 2.0;
var y = -point2d.y * this.workingHeight + this.workingHeight / 2.0;
return ({
Coordinates: new BABYLON.Vector3(x, y, point2d.z),
Normal: normal3DWorld,
WorldCoordinates: point3DWorld
});
}
// 在两点之间从左到右绘制一条线段
// papb -> pcpd
// pa, pb, pc, pd 在之前必须已经排好序
public processScanLine(data: ScanLineData,
va: Vertex,
vb: Vertex,
vc: Vertex,
vd: Vertex,
color: BABYLON.Color4): void {
var pa = va.Coordinates;
var pb = vb.Coordinates;
var pc = vc.Coordinates;
var pd = vd.Coordinates;
// 由当前的 y 值,我们可以计算出梯度
// 以此再计算出 起始 X(sx) 和 结束 X(ex)
// 如果 pa.Y == pb.Y 或者 pc.Y== pd.y 的话,梯度强制为 1
var gradient1 = pa.y != pb.y ? (data.currentY - pa.y) / (pb.y - pa.y) : 1;
var gradient2 = pc.y != pd.y ? (data.currentY - pc.y) / (pd.y - pc.y) : 1;
var sx = this.interpolate(pa.x, pb.x, gradient1) >> 0;
var ex = this.interpolate(pc.x, pd.x, gradient2) >> 0;
// 开始 Z 值和结束 Z 值
var z1: number = this.interpolate(pa.z, pb.z, gradient1);
var z2: number = this.interpolate(pc.z, pd.z, gradient2);
// 从左(sx) 向右(ex) 绘制一条线
for (var x = sx; x < ex; x++) {
var gradient: number = (x - sx) / (ex - sx);
var z = this.interpolate(z1, z2, gradient);
var ndotl = data.ndotla;
// 基于光向量和法线向量之间角度的余弦改变颜色值
this.drawPoint(new BABYLON.Vector3(x, data.currentY, z), new BABYLON.Color4(color.r * ndotl, color.g * ndotl, color.b * ndotl, 1));
}
}
// 计算光向量和法线向量之间角度的余弦
// 返回 0 到 1 之间的值
public computeNDotL(vertex: BABYLON.Vector3,
normal: BABYLON.Vector3,
lightPosition: BABYLON.Vector3): number {
var lightDirection = lightPosition.subtract(vertex);
normal.normalize();
lightDirection.normalize();
return Math.max(0, BABYLON.Vector3.Dot(normal, lightDirection));
}
public drawTriangle(v1: Vertex,
v2: Vertex,
v3: Vertex,
color: BABYLON.Color4): void {
// 进行排序,p1 总在最上面,p2 总在最中间,p3 总在最下面
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
if (v2.Coordinates.y > v3.Coordinates.y) {
var temp = v2;
v2 = v3;
v3 = temp;
}
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
var p1 = v1.Coordinates;
var p2 = v2.Coordinates;
var p3 = v3.Coordinates;
// 法线面上的向量是该法线面和每个顶点法线面中心点的平均值
var vnFace = (v1.Normal.add(v2.Normal.add(v3.Normal))).scale(1 / 3);
var centerPoint = (v1.WorldCoordinates.add(v2.WorldCoordinates.add(v3.WorldCoordinates))).scale(1 / 3);
// 光照位置
var lightPos = new BABYLON.Vector3(0, 10, 10);
// 计算光向量和法线向量之间夹角的余弦
// 它会返回介于 0 和 1 之间的值,该值将被用作颜色的亮度
var ndotl = this.computeNDotL(centerPoint, vnFace, lightPos);
var data: ScanLineData = {
ndotla: ndotl
};
// 计算线条的方向
var dP1P2: number;
var dP1P3: number;
// http://en.wikipedia.org/wiki/Slope
// 计算斜率
if (p2.y - p1.y > 0) dP1P2 = (p2.x - p1.x) / (p2.y - p1.y);
else dP1P2 = 0;
if (p3.y - p1.y > 0) dP1P3 = (p3.x - p1.x) / (p3.y - p1.y);
else dP1P3 = 0;
// 在第一种情况下,三角形是这样的:
// P1
// -
// --
// - -
// - -
// - - P2
// - -
// - -
// -
// P3
if (dP1P2 > dP1P3) {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
data.currentY = y;
if (y < p2.y) {
this.processScanLine(data, v1, v3, v1, v2, color);
} else {
this.processScanLine(data, v1, v3, v2, v3, color);
}
}
}
// 在第二种情况下,三角形是这样的:
// P1
// -
// --
// - -
// - -
// P2 - -
// - -
// - -
// -
// P3
else {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
data.currentY = y;
if (y < p2.y) {
this.processScanLine(data, v1, v2, v1, v3, color);
} else {
this.processScanLine(data, v2, v3, v1, v3, color);
}
}
}
}
【译者注:JavaScript 代码】
// 将三维坐标和变换矩阵转换成二维坐标
Device.prototype.project = function (vertex, transMat, world) {
// 将坐标转换为二维空间
var point2d = BABYLON.Vector3.TransformCoordinates(vertex.Coordinates, transMat);
// 在三维世界中转换坐标和法线的顶点
var point3DWorld = BABYLON.Vector3.TransformCoordinates(vertex.Coordinates, world);
var normal3DWorld = BABYLON.Vector3.TransformCoordinates(vertex.Normal, world);
// 变换后的坐标起始点是坐标系的中心点
// 但是,在屏幕上,我们以左上角为起始点
// 我们需要重新计算使他们的起始点变成左上角
var x = point2d.x * this.workingWidth + this.workingWidth / 2.0;
var y = -point2d.y * this.workingHeight + this.workingHeight / 2.0;
return ({
Coordinates: new BABYLON.Vector3(x, y, point2d.z),
Normal: normal3DWorld,
WorldCoordinates: point3DWorld
});
};
// 在两点之间从左到右绘制一条线段
// papb -> pcpd
// pa, pb, pc, pd 在之前必须已经排好序
Device.prototype.processScanLine = function (data, va, vb, vc, vd, color) {
var pa = va.Coordinates;
var pb = vb.Coordinates;
var pc = vc.Coordinates;
var pd = vd.Coordinates;
// 由当前的 y 值,我们可以计算出梯度
// 以此再计算出 起始 X(sx) 和 结束 X(ex)
// 如果 pa.Y == pb.Y 或者 pc.Y== pd.y 的话,梯度强制为 1
var gradient1 = pa.y != pb.y ? (data.currentY - pa.y) / (pb.y - pa.y) : 1;
var gradient2 = pc.y != pd.y ? (data.currentY - pc.y) / (pd.y - pc.y) : 1;
var sx = this.interpolate(pa.x, pb.x, gradient1) >> 0;
var ex = this.interpolate(pc.x, pd.x, gradient2) >> 0;
// 开始 Z 值和结束 Z 值
var z1 = this.interpolate(pa.z, pb.z, gradient1);
var z2 = this.interpolate(pc.z, pd.z, gradient2);
// 从左(sx) 向右(ex) 绘制一条线
for (var x = sx; x < ex; x++) {
var gradient = (x - sx) / (ex - sx);
var z = this.interpolate(z1, z2, gradient);
var ndotl = data.ndotla;
// 基于光向量和法线向量之间角度的余弦改变颜色值
this.drawPoint(new BABYLON.Vector3(x, data.currentY, z),
new BABYLON.Color4(color.r * ndotl, color.g * ndotl, color.b * ndotl, 1));
}
};
// 计算光向量和法线向量之间角度的余弦
// 返回 0 到 1 之间的值
Device.prototype.computeNDotL = function (vertex, normal, lightPosition) {
var lightDirection = lightPosition.subtract(vertex);
normal.normalize();
lightDirection.normalize();
return Math.max(0, BABYLON.Vector3.Dot(normal, lightDirection));
};
Device.prototype.drawTriangle = function (v1, v2, v3, color) {
// 进行排序,p1 总在最上面,p2 总在最中间,p3 总在最下面
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
if (v2.Coordinates.y > v3.Coordinates.y) {
var temp = v2;
v2 = v3;
v3 = temp;
}
if (v1.Coordinates.y > v2.Coordinates.y) {
var temp = v2;
v2 = v1;
v1 = temp;
}
var p1 = v1.Coordinates;
var p2 = v2.Coordinates;
var p3 = v3.Coordinates;
// 法线面上的向量是该法线面和每个顶点法线面中心点的平均值
var vnFace = (v1.Normal.add(v2.Normal.add(v3.Normal))).scale(1 / 3);
var centerPoint = (v1.WorldCoordinates.add(v2.WorldCoordinates.add(v3.WorldCoordinates))).scale(1 / 3);
// 光照位置
var lightPos = new BABYLON.Vector3(0, 10, 10);
// 计算光向量和法线向量之间夹角的余弦
// 它会返回介于 0 和 1 之间的值,该值将被用作颜色的亮度
var ndotl = this.computeNDotL(centerPoint, vnFace, lightPos);
var data = { ndotla: ndotl };
// 计算线条的方向
var dP1P2;
var dP1P3;
// http://en.wikipedia.org/wiki/Slope
// 计算斜率
if (p2.y - p1.y > 0)
dP1P2 = (p2.x - p1.x) / (p2.y - p1.y); else
dP1P2 = 0;
if (p3.y - p1.y > 0)
dP1P3 = (p3.x - p1.x) / (p3.y - p1.y); else
dP1P3 = 0;
// 在第一种情况下,三角形是这样的:
// P1
// -
// --
// - -
// - -
// - - P2
// - -
// - -
// -
// P3
if (dP1P2 > dP1P3) {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
data.currentY = y;
if (y < p2.y) {
this.processScanLine(data, v1, v3, v1, v2, color);
} else {
this.processScanLine(data, v1, v3, v2, v3, color);
}
}
}
// 在第二种情况下,三角形是这样的:
// P1
// -
// --
// - -
// - -
// P2 - -
// - -
// - -
// -
// P3
else {
for (var y = p1.y >> 0; y <= p3.y >> 0; y++) {
data.currentY = y;
if (y < p2.y) {
this.processScanLine(data, v1, v2, v1, v3, color);
} else {
this.processScanLine(data, v2, v3, v1, v3, color);
}
}
}
};
要查看浏览器中的效果,请点击下面的截图:
3D 软件渲染引擎: 在浏览器中查看 Html5 平面着色演示
在我的联想 X1 Carbon (酷睿 i7 lvy Bridge)中,使用 Internet Explorer 11(这似乎是我的 Windows8.1 机器中最快的浏览器) 我跑这个 640x480 的实现大约可以跑到 35FPS,并且在 Surface RT 中大约可以得到 4FPS 每秒的执行速度。C#的并行版本渲染同样的场景则可以运行在 60FPS 速度下。
你可以在这里下载执行这一平面渲染解决方案:
TypeScript: SoftEngineTSPart5FlatShading.zip
- JavaScript: SoftEngineJSPart5FlatShading.zip
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论