【笔记】《WebGL编程指南》学习(10)

WebGL编程指南学习(10)

This is just the end of the beginning. —— Winston Curchill

10. 高级技巧(又续)

绘制阴影

实现阴影有若干种不同的方法,书里的例子是阴影贴图(Shadow map)或称深度贴图(Depth map)

如何实现阴影?

  • 物理上:对一根光线上的两个点,如果一个点的z值大于另一个点,则它在阴影中
  • 算法上:
    • 计算光源到物体的距离
    • 根据计算出的结果,绘制场景
  • 计算机实现上:
    • 一组着色器用来计算光源到物体的距离,得到一张纹理图像(阴影贴图),并将结果传入另一组串色器
      • 将视点移到光源位置处,运行着色器。这时记录可见的片元的z值,并写入阴影贴图中
      • 这里使用帧缓冲区对象记录片元到光源的距离
    • 第二组着色器通过阴影贴图实现阴影
      • 将视点移回原来的位置,绘制场景。此时,计算每个原片在光源坐标系下的坐标,并与阴影贴图中记录的z值比较,如果前者大于后者,说明当前片元处在阴影中,用较深暗的颜色绘制
  • 这里用到了帧缓冲区和切换着色器技术
// 第一组着色器:生成阴影贴图
// 顶点缓冲区
var SHADOW_VSHADER_SOURCE = 
    //...
    'void main() {\n' +
    '	gl_Position = u_MvpMatrix * a_Position;\n' +
    '}\n';
// z值缓冲区
var SHADOW_FSHADER_SOURCE = 
    //...
    'void main() {\n}' +
    '	gl_FragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 0.0);\n' +
    '}\n';
// 第二组着色器:正常绘制
var VSHADER_SOURCE = 
    //...
    'void main() {\n' +
    '	gl_Position = u_MvpMatrix * a_Position;\n' + 
    '	v_PositionFromLight = u_MvpMatrixFromLight * a_Position;\n' +
    //...
var FSHADER_SOURCE = 
  	//...
  	'uniform sampler2D u_ShadowMap;\n' +
  	//...
  	'void main() {\n' +
  	'	vec3 shadowCoord = (v_PositionFromLight.xyz/v_PositionFromLight.w)/2.0 + 0.5;\n' +
  	'	vec4 rgbaDepth = texture2D(u_ShadowMap, shadowCoord.xy);\n' +
  	'	float depth = rgbaDepth.r;\n' +
  	'	float visibility = (shadowCoord.z > depth + 0.005) ? 0.7 : 1.0;\n' +
  	'	gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a);\n' +
  	'}\n';
第一组着色器
  • 将绘制目标切换到帧缓冲区对象,把视点在光源处的MVP矩阵传给u_MvpMatrix
  • 片元着色器把片元的z值写入了纹理贴图
gl_FragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 0.0);
  • 绘制结果是个纹理对象,要传给另一对着色器(Javascript完成)
第二组着色器
  • 这里的u_MvpMatrix是视点在远处的MVP矩阵,而视点位于光源处的MVP矩阵是u_MvpMatrixFromLight
  • z值的计算方法就是:
gl_Position.z/gl_Position.w/2.0+0.5;

这个计算方法就是默认的gl_FragCoord.z。只不过这里gl_FragCoord不是要求的,所以得显示写出这个计算过程

这里恰好,xy坐标的归一化方式和纹理坐标归一化一致,所以一行代码算出的就是纹理上的坐标

  • 从阴影贴图中抽取纹素,然后比较depth和shadowCoord.z
  • 比较的时候添加了一个0.005的偏移量,目的是避免马赫带效应(计算误差)
JavaScript部分
var OFFSCREEN_WIDTH = 1024, OFFSCREEN_HEIGHT = 1024; // 渲染阴影贴图的纹理大小
var LIGHT_X = 0, LIGHT_Y = 7, LIGHT_Z = 2; // 光源位置

function main() {
  //...
  // 第一组着色器
  var shadowProgram = createProgram(gl, SHADOW_VSHADER_SOURCE, SHADOW_FSHADER_SOURCE);
  // 第二组着色器
  var normalProgram = createProgram(gl, VSHADER_SOURCE, FSHADER_SOURCE);
  //...
  // 设置顶点信息
  var triangle = initVertexBuffersForTriangle(gl);
  var plane = initVertexBufferForPlane(gl);
  //...
  // 初始化帧缓冲区(FBO)
  var fbo = initFrameBufferObject(gl);
  //...
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, fbo.texture) // 把纹理对象挂到FBO的texture上
  //...
  // 计算视点到光源的MVP,为渲染阴影贴图准备
  var viewProjMatrixFromLight = new Matrix4();
  viewProjMatrixFromLight.setPerspective(70.0, OFF_SCREEN_WIDTH/OFF_SCREEN_HEIGHT, 1.0, 100.0);
  viewProjMatrixFromLight.lookAt(LIGHT_X, LIGHT_Y, LIGHT_Z, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);
  // 设置正常视点的MVP,为正常渲染准备
  var viewProjMatrix = new Matrix4();
  viewProjMatrix.setPerspective(45, canvas.width/canvas.height, 1.0, 100.0);
  viewProjMatrix.lookAt(0.0, 7.0, 9.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);
  // Bonus: 额外任务,让三角形旋转起来!
  var currentAngle = 0.0; // 当前旋转角度
  var mvpMatrixFromLight_t = new Matrix4(); // 三角形的MVP
  var mvpMatrixFromLight_p = new Matirx4(); // 平面的MVP
  var tick = function(){
    currentAngle = animate(currentAngle);
    // 将渲染对象切换为FBO
    gl.bindFrameBuffer(gl.FRAMEBUFFER, fbo);
    //...
    gl.useProgram(shadowProgram);
    // 渲染以生成纹理贴图
    drawTriangle(gl, shadowProgram, triangle, currentAngle, viewProjMatrixFromLight);
    mvpMatrixFromLight_t.set(g_mvpMatrix);
    drawPlane(gl, shadowProgram, plane, viewProjMatrixFromLight);
    mvpMatrixFromLight_p.set(g_mvpMatrix);
    
    // 正常渲染
    gl.bindFrameBuffer(gl.FRAMEBUFFER, null);
    gl.useProgram(normalProgram);
    gl.uniform1i(normalProgram.u_ShadowMap, 0); // 传递gl.TEXTURE0
    gl.uniformMatrix4fv(normalProgram.u_MvpMatrixFromLight, false, mvpMatirxFromLighjt_t.elements);
    drawTriangle(gl, normalProgram, triangle, currentAngle, viewProjMatrix);
    gl.uniformMatrix4fv(normalProgram.u_MvpMatrixFromLight, false, mvpMatirxFromLighjt_p.elements);
    drawTriangle(gl, normalProgram, plane, currentAngle, viewProjMatrix);
    
    window.requestAnimationFrame(tick, canvas);
  };
  tick();
}

提高精度

如果光源与照射物体变远,gl_FragCoord.z的值也会增大。当光源足够远时,gl_FragCoord.z大到无法存储在只有8位的R分量中了。

咋办呢?

使用阴影贴图的RGBA4个分量,共32位来存储z值。

pack压缩

把gl_FragCoord.z拆为4个字节RGBA,每个字节的精度是1/256,所以把大于1/256的部分存在R中,1/256到1/(256*256)的部分存在G中,以此类推。使用内置函数fract()计算上述分量的值。fract()的作用是舍弃整数部分,返回小数部分.

const vec4 bitShift = vec4(1.0, 256.0, 256.0 * 256.0, 256.0 * 256.0 * 256.0);
vec4 rgbaDepth = fract(gl_FragCoord.z * bitShift);

因为rgbaDepth是vec4类型的精度高于8位,还需要将多余的部分砍掉

const vec4 bitMask = vec4(1.0/256.0, 1.0/256.0, 1.0/256.0, 0.0);
rgbaDepth -= rgbaDepth.gbaa * bitMask;

例如:若要保存的深度值为:0.111,经过运算以后是(0.111, 0.416, 0.496, 0.976)

bitMask计算结果是(0.001625, 0.0019375, 0.0038125, 0)

再经过修改变成(0.109375, 0.4140625, 0.4921875, 0.976)

unpack的结果是0.111

由此可见,这里深度范围不能超过1。

unpack解压

d e p t h = r g b a D e p t h . r ∗ 1.0 + r g b a D e p t h . g 256.0 + r g b a D e p t h . b 256.0 ∗ 256.0 + r g b a D e p t h . a 256.0 ∗ 256.0 ∗ 256.0 depth = rgbaDepth.r * 1.0 + \frac{rgbaDepth.g}{256.0} + \frac{rgbaDepth.b}{256.0*256.0}+\frac{rgbaDepth.a}{256.0*256.0*256.0} depth=rgbaDepth.r1.0+256.0rgbaDepth.g+256.0256.0rgbaDepth.b+256.0256.0256.0rgbaDepth.a

这个可以通过内置函数dot()实现

const vec4 bitShit = vec4(1.0, 1.0/256.0, 1.0/256.0/256.0, 1.0/256.0/256.0/256.0);
depth = dot(rgbaDepth, bitShift);

加载三维模型

读取三维模型文件,只需要搞清楚文件格式即可。

读取文件数据

  1. 准备Float32Array类型的数组vertices,从文件中读取模型的顶点坐标数据并保存到其中;
  2. 准备Float32Array类型的数组colors,从文件中读取模型的顶点颜色数据并保存到其中;
  3. 准备Float32Array类型的数组normals,从文件中读取模型的顶点法线数据并保存到其中。
  4. 准备Uint16Array或Uint8Array类型的数组indices,从文件中读取模型的顶点索引数据并保存在其中。
  5. 将前4步获取的数据写入缓冲区中
OBJ文件格式
# Blender v2.60 (sub 0) OBJ File: "
# www.blender.org
mtllib cube.mtl
o Cube
v 1.000000 -1.000000 -1.000000
...
usemtl Material
f 1 2 3 4
...
usemtl Material.001
f 1 5 6 2
  • #开头的行表示注释
  • 引用外部材质文件MTL格式的文件 cube.mtl
  • 指定模型名称
  • 定义顶点的坐标,其中w是可选的,如果没有就默认是1.0
  • 指定某个材质,usemtl <材质名>
  • 列举使用这个材质的表面,每个表面是由顶点、纹理坐标和发现的索引序列定义的。其中v1,v2,v3,v4是顶点的索引值。示例中没有包含法线,如果需要的话格式应该是f v1//vn1 v2//vn2 v3//vn3 …。这里索引值从1开始
  • 示例中,使用另一个材质 usemtl Material.001
MTL文件格式
# Blender MTL File:"
# Material Count: 2
newmtl Material
Ka 0.000000 0.000000 0.000000
Kd 1.000000 0.000000 0.000000
Ks 0.000000 0.000000 0.000000
Ns 96.078431
Ni 1.000000
d 1.000000
illum 0
newmtl Material.001
...
  • 定义一个新材质 newmtl <材质名>
  • 使用Ka, Kd, Ks定义表面的环境色、漫射色和高光色。每个颜色使用RGB格式定义,每个分量值的区间为[0.0, 1.0]。
  • 使用Ks指定高光色的权重,使用Ni指定了表面光学密度,使用d指定了透明度,使用illum指定了光照模型

在理解模型文件的基础上,就可以使用JavaScript文件进行读取和解析了。

OBJ模型解析

**算法步骤 **

  • 准备一个空的缓冲区对象
function main() {
  ...
  // 为顶点坐标、颜色和法线准备空缓冲区对象
  var model = initVertexBuffers(gl, program);
  ...
  // 读取OBJ文件
  readOBJFile('../resources/cube.obj', gl, model, 60, true);
  ...
}
// 创建缓冲区对象并进行初始化
function initVertexBuffers(gl, program) {
  var o = new Object();
  o.vertexBuffer = createEmptyArrayBuffer(gl, program.a_Position, 3, gl.FLOAT);
  o.normalBuffer = createEmptyArrayBuffer(gl, program.a_Normal, 3, gl.FLOAT);
  o.colorBuffer = createEmptyArrayBuffer(gl, program.a_Color, 4, gl.FLOAT);
  o.indexBuffer = gl.createBuffer();
  ...
  return o;
}
// 创建缓冲区对象,并将其分配给相应的attribute变量,并开启之
function createEmptyArrayBuffer(gl, a_attribute, num, type) 
{
  var buffer = gl.createBuffer();
  ...
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.vertexAttribPointer(a_attribute, num, type, false, 0, 0);
  gl.enableVertexAttribArray(a_attribute); // 开启attribute变量
  
  return buffer;
}
  • 读取OBJ文件
// 读取OBJ文件
function readOBJFile(fileName, gl, model, scale, reverse)
{
  var request = new XMLHttpRequest();
  
  request.onreadystatechange = function() {
    if (request.readyState === 4 && request.status !== 404) {
      onReadOBJFile(request.responseText, fileName, gl, model, scale, reverse);
    }
  }
  request.open('GET', fileName, true); // 创建请求
  request.send(); // 发起请求
}
  1. 创建一个XMLHttpRequest对象
  2. 注册事件响应函数,当加载模型文件完成时调用(request.onreadystatechange)
  3. 使用open方法创建一个请求,以加载模型文件
  4. 使用send方法发起请求,开始加载模型文件

事件响应函数

  • 检查加载请求是否发生了错误。readyState是4,表示加载完成了;readyState是404,表示打不开文件
  • 如果成功加载,就调用onReadOBJFile解析模型中的内容。onReadOBJFile第一个参数responseText是字符串形式的模型文件的文本
  • onReadOBJFile调用parse方法将字符串文本解析成WebGL易用的格式
  • 最后将解析好的objDoc对象赋值给全局对象g_objDoc,供其他函数使用

模型读取函数

// 读取
function onReadOBJFile(fileString, fileName, gl, o, scale ,reverse)
{
  var objDoc = new OBJDoc(fileName); // 创建OBJDoc对象
  var result = objDoc.parse(fileString, scale, reverse); // 调用解析方法
  ...
  g_objDoc = objDoc;
}
  • 解析模型文本
// 定义OBJDoc类
// 构造函数
var OBJDoc = function(fileName){
  this.fileName = fileName;
  this.mtls = new Array(0); // 材质MTL列表
  this.objects = new Array(0); // 对象object列表
  this.vertices = new Array(0); // 顶点Vertex列表
  this.normals = new Array(0); // 法线Normal列表
}
// OBJDoc类的parse方法,用来解析obj文件中的字符串文本
OBJDoc.prototype.parse = function(fileString, scale, reverseNormal)
{
  var lines = fileString.split('\n'); // 首先把字符串拆成一行一行
  lines.push(null);	// 添加行末标识
  var index = 0; // 这是行索引
  
  var currentObject = null;
  var currentMaterialName = "";
  
  // 逐行解析
  var line; // 接收当前行文本
  var sp = new StringParser(); // 这是StringParser,自定义字符串解析类的一个实例
  while ((line = lines[index++]) != null){
    sp.init(line); // 初始化sp
    var command = sp.getWord(); // 获取指令名,每一行的第一个单词
    if (command == null) continue;
    switch (command){
      case '#':
        continue; // 这是注释行,直接跳过
      case 'mtllib': // 这行是要读取某个材质文件,文件名是下一个单词
        var path = this.parseMtllib(sp, this.fileName);
        var mtl = new MTLDoc(); // 创建新的MTL类实例
        this.mtls.push(mtl); // 把这个mtl实例推入mtl数组
        // 类似obj文件的方式,使用XMLHttprequest读取mtl文件
        //...
        continue;
      case 'o':
      case 'g':	// 读取对象名称(模型叫啥)
        var object = this.parseObjectName(sp);
        this.objects.push(object); // 把这个模型送入数组
        currentObject = object; // 当前处理的是currentObject模型的数据
        continue;
      case 'v':
        var vertex = this.parseVertex(sp, scale); // 解析顶点,这里scale是缩放因子
        this.vertices.push(vertex); // 顶点推入顶点数组里
        continue;
      case 'vn':
        var normal = this.parseNormal(sp); // 解析法向
        this.normals.push(normal);
        continue;
      case 'usemtl': // 这行要求读取某材质,后面跟的是材质名
        currentMaterialName = this.parseUsemtl(sp);
        continue;
      case 'f': // 读取表面
        var face = this.parseFace(sp, currentMaterialName, this.vertices, reverse);
        currentObject.addFace(face); // 给当前处理的object添加face数据
        continue;
    }
  }
  return true;
}

自定义的StringParse类支持的方法

方法描述
StringParser.init(str)初始化StringParser对象
StringParser.getWord()获取一个单词
StringParser.skipToNextWord()跳至下一个单词
StringParser.getInt()获取单词并将其转化为整型
StringParser.getFloat()获取单词并将其转化为浮点数

不同类型数据的解析方法——以顶点为例

OBJDoc.prototype.parseVertex = function(sp, scale){
  var x = sp.getFloat() * scale;
  ...
  return (new Vertex(x,y,z));
}

把存储在数组的值转成绘制用的数组(Float32Array等格式)

  • 通过for循环计算出顶点索引的数量,然后创建数个类型化数组以分别存储顶点坐标、法线向量、颜色和索引值的数据,并写入相应的缓冲区对象
  • 通过for循环逐步抽取,最外层抽取不同的OBJObject对象;内层则逐对象抽取Face对象;
  • 最内层使用materialName抽取表面的颜色,保存在color中;获取表面的法线向量,保存在faceNormal中
  • 再使用一层for循环,抽取表面的每个顶点索引,将顶点坐标存入vertices中,将颜色值存入colors,法线向量寸入normals。由于有的OJB文件没有发现,这时就用上面解析时的表面法线向量拷贝之。
for (var i = 0; i < this.objects.length; i++){
  var object = this.objects[i];
  for (var j = 0; j < object.faces.length; j++){
    var face = obejct.face[j];
    var color = this.findColor(face.materialName);
    var faceNormal = face.normal;
    for (var k = 0; k < face.vIndices.length; k++){
      // 设置索引
      indices[index_indices] = index_indices;
      // 复制顶点
      var vIdx = face.vIndices[k];
      var vertex = this.vertices[vIdx]; // 从总的那个vertex数组中检索
      vertices[index_indices * 3 + 0] = vertex.x;
      vertices[index_indices * 3 + 1] = vertex.y;
      vertices[index_indices * 3 + 2] = vertex.z;
      // 复制颜色
      colors[index_indices * 4 + 0] = color.r;
      colors[index_indices * 4 + 1] = color.g;
      colors[index_indices * 4 + 2] = color.b;
      colors[index_indices * 4 + 3] = color.a;
      // 复制法向
      var nIdx = face.nIndices[k];
      if (nIdx >= 0){
        // 说明原始文件中有法向信息
        var normal = this.normals[nIdx];
        normals[index_indices * 3 + 0] = normal.x;
        normals[index_indices * 3 + 1] = normal.y;
        normals[index_indices * 3 + 2] = normal.z;
      }
      else{
        // 直接复制表面的法向
        normals[index_indices * 3 + 0] = faceNormal.x;
        normals[index_indices * 3 + 0] = faceNormal.x;
        normals[index_indices * 3 + 0] = faceNormal.x;
      }
      index_indices ++;
    }
  }
}

响应上下文丢失

计算机从休眠中唤醒,或者后台切换,有可能会导致WebGL程序停止,这种现象就是上下文丢失。

如何响应上下文丢失?

webglcontextlost // 上下文丢失事件
webglcontextresotred // 上下文恢复事件

WebGL提供了两个事件,上下文丢失事件和上下文恢复事件。

  • 上下文事件丢失时,由getWebGLContext()获取渲染上下文对象gl就失效了,基于gl的所有操作也都失效了。
  • 浏览器重置WebGL系统后,触发了上下文恢复事件,这是要重新完成上述步骤。
    • 注意:在JavaScript中保存的变量并没有收到影响

给canvas注册上下文丢失和上下文恢复事件的响应函数

canvas.addEventListener(type, handler, useCapture)
// 把handler作为type事件的响应函数注册到canvas元素上去
// 参数:
// 	type:监听事件的名称、字符串
//  handler: 响应函数
//  useCapture: 事件触发后是否捕获。true的话就捕获事件,canvas的父元素就不会触发该事件;false的话,事件触发后还要向上层继续传递
function main(){
  var canvas = document.getElementById('webgl');
  // 注册事件响应函数
  canvas.addEventListener('webglcontextlost', contextLost, false);
  canvas.addEventListener('webglcontextrestored', function(ev){start(canvas);}, false);
  
  start(canvas);
}

调用start方法渲染上下文

function start(canvas)
{
  var gl = getWebGLContext(canvas);
  ...
}

通过把原本main里的绝大多数操作转移到start函数里,当上下文丢失又恢复的时候,再次调用该函数就可以了。

需要注意的是:

  1. 一些上下文丢失后丢失的局部变量,要改到全局变量中。这样就与上下文恢复无关了
  2. 一些函数返回值是在局部调用的,为了防止上下文丢失,可以将返回值保存在全局变量中

上下文丢失响应函数

function contextLost(ev){
  cancelAnimationFrame(g_requestID); // 停止动画
  ev.preventDefault(); // 阻止默认行为
}

上下文丢失响应函数就保证在上下文恢复前,不再尝试重绘、阻止浏览器对该事件的默认处理行为。浏览器对上下文丢失事件的默认处理行为是,不再触发上下文恢复事件。而我们这里要触发上下文恢复事件,所以要组织浏览器的默认行为。

上下文恢复响应函数

调用start即可重置WebGL系统。这里用匿名函数。

基本信息 原书名:WebGL Programming Guide: Interactive 3D Graphics Programming with WebGL (OpenGL) 原出版社: Addison-Wesley Professional 作者: (美)Kouichi Matsuda Rodger Lea(松田浩一,罗杰.李) 译者: 谢光磊 出版社:电子工业出版社 ISBN:9787121229428 上架时间:2014-6-11 出版日期:2014 年6月 开本:16开 页码:470 版次:1-1 ---------------------------------------- 目录 《WebGL编程指南》 第1 章 WebGL 概述 1 WebGL 的优势 3 使用文本编辑器开发三维应用 3 轻松发布三维图形程序 4 充分利用浏览器的功能 5 学习和使用WebGL 很简单 5 WebGL 的起源 5 WebGL 程序的结构 6 总结 7 第2 章 WebGL 入门 9 Canvas 是什么? 10 使用[canvas] 标签 11 DrawRectangle.js 13 最短的WebGL 程序:清空绘图区 16 HTML 文件(HelloCanvas.html) 16 JavaScript 程序(HelloCanvas.js) 17 用示例程序做实验 22 绘制一个点(版本1) 22 HelloPoint1.html 24 HelloPoint1.js 24 着色器是什么? 25 使用着色器的WebGL 程序的结构 27 初始化着色器 29 顶点着色器 31 片元着色器 33 绘制操作 34 WebGL 坐标系统 35 用示例程序做实验 37 绘制一个点(版本2) 38 使用attribute 变量 38 示例程序(HelloPoint2.js) 39 获取attribute 变量的存储位置 41 向attribute 变量赋值 42 gl.vertexAttrib3f() 的同族函数 44 用示例程序做实验 45 通过鼠标点击绘点 46 示例程序(ClickedPoints.js) 47 注册事件响应函数 48 响应鼠标点击事件 50 用示例程序做实验 53 改变点的颜色 55 示例程序(ColoredPoints.js) 56 uniform 变量 58 获取uniform 变量的存储地址 59 向uniform 变量赋值 60 gl.uniform4f() 的同族函数 61 总结 62 第3 章 绘制和变换三角形 63 绘制多个点 64 示例程序(MultiPoint.js) 66 使用缓冲区对象 69 创建缓冲区对象(gl.createBuffer()) 70 绑定缓冲区(gl.bindBuffer()) 71 向缓冲区对象中写入数据(gl.bufferData()) 72 类型化数组 74 将缓冲区对象分配给attribute 变量(gl.vertexAttribPointer()) 75 开启attribute 变量(gl.enableVertexAttribArray()) 77 gl.drawArrays() 的第2 个和第3 个参数 78 用示例程序做实验 79 Hello Triangle 80 示例程序(HelloTriangle.js) 80 基本图形 82 用示例程序做实验 83 Hello Rectangle(HelloQuad) 84 用示例程序做实验 85 移动、旋转和缩放 86 平移 87 示例程序(TranslatedTriangle.js) 88 旋转 91 示例程序(RotatedTriangle.js) 93 变换矩阵:旋转 97 变换矩阵:平移 100 4×4 的旋转矩阵 101 示例程序(RotatedTriangle_Matrix.js) 102 平移:相同的策略 105 变换矩阵:缩放 106 总结 108 第4 章 高级变换与动画基础 109 平移,然后旋转 109 矩阵变换库:cuon-matrix.js 110 示例程序(RotatedTriangle_Matrix4.js) 111 复合变换 113 示例程序(RotatedTranslatedTriangle.js) 115 用示例程序做实验 117 动画 118 动画基础 119 示例程序(RotatingTriangle.js) 119 反复调用绘制函数(tick()) 123 按照指定的旋转角度绘制三角形(dr
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值