diff --git a/webgl/lessons/zh_cn/webgl-anti-patterns.md b/webgl/lessons/zh_cn/webgl-anti-patterns.md new file mode 100644 index 000000000..90f1bf3b1 --- /dev/null +++ b/webgl/lessons/zh_cn/webgl-anti-patterns.md @@ -0,0 +1,255 @@ +Title: WebGL2 反模式 +Description: WebGL 编程禁忌:问题所在与正确做法 +TOC: 反模式 + + +这是一些 WebGL 中的**反模式**列表。反模式指的是你在编写 WebGL 程序时应当**避免采用的做法**。 + +1. 在 `WebGLRenderingContext` 上添加 `viewportWidth` 和 `viewportHeight` 属性 + + 有些代码会为视口的宽度和高度添加属性,并将它们直接附加到 `WebGLRenderingContext` 对象上,类似这样: + + gl = canvas.getContext("webgl2"); + gl.viewportWidth = canvas.width; // ❌ 错误做法! + gl.viewportHeight = canvas.height; // ❌ 错误做法! + + 之后可能会这样使用这些属性: + + gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight); + + **为什么这样做不好:** + + 从客观角度来看,这样做不好是因为你引入了两个属性,在每次更改 canvas 大小时都需要手动更新它们。 + 例如:当用户调整窗口大小时,如果你没有重新设置 `gl.viewportWidth` 和 `gl.viewportHeight`,它们的值就会出错。 + + 从主观角度来看,这样做也不好,因为任何一个刚接触 WebGL 的程序员在看到你的代码时, + 很可能会以为 `gl.viewportWidth` 和 `gl.viewportHeight` 是 WebGL 规范的一部分, + 从而产生误解,甚至困扰数月。 + + **正确的做法:** + + 为什么要给自己增加额外的工作量?WebGL 上下文对象中已经包含了其对应的 canvas,而且 canvas 本身就有宽高属性可用。 + +
+    gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
+    
+ + 上下文对象本身也直接提供了其绘图缓冲区的宽度和高度。 + + // 当你需要将视口设置为与 canvas 的 drawingBuffer 大小时,这种方式总是正确的 + gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); + + 甚至更好的是,使用`gl.drawingBufferWidth` 和 `gl.drawingBufferHeight` 能处理极端情况,而使用 `gl.canvas.width` 和 `gl.canvas.height` 则无法做到。为什么会这样[请见此处](#drawingbuffer)。 + +2. 使用 canvas.width 和 canvas.height 来计算宽高比(aspect ratio) + + 很多代码会像下面这样,使用 `canvas.width` 和 `canvas.height` 来计算宽高比: + + var aspect = canvas.width / canvas.height; + perspective(fieldOfView, aspect, zNear, zFar); + + **为什么这样做不好:** + + 画布的 width 和 height 属性与画布在页面上的实际显示尺寸没有关系。 真正控制画布显示大小的是 CSS。 + + **正确的做法:** + + 使用 `canvas.clientWidth` 和 `canvas.clientHeight`。这些值表示画布在屏幕上实际的显示尺寸。使用它们可以确保你始终获得正确的纵横比,而不受 CSS 设置的影响。 + + var aspect = canvas.clientWidth / canvas.clientHeight; + perspective(projectionMatrix, fieldOfView, aspect, zNear, zFar); + + 以下是一些示例:画布的绘图缓冲区尺寸相同(width="400" height="300"),但我们通过 CSS 指定浏览器以不同的尺寸显示该画布。 请注意,这些示例中的 “F” 字母都显示在正确的宽高比下。 + + {{{diagram url="../webgl-canvas-clientwidth-clientheight.html" width="150" height="200" }}} +

+ {{{diagram url="../webgl-canvas-clientwidth-clientheight.html" width="400" height="150" }}} + + 如果我们使用的是 `canvas.width` 和 `canvas.height`,那么就不会是这种正确的显示效果了。 + + {{{diagram url="../webgl-canvas-width-height.html" width="150" height="200" }}} +

+ {{{diagram url="../webgl-canvas-width-height.html" width="400" height="150" }}} + +3. 使用 `window.innerWidth` 和 `window.innerHeight` 来进行计算 + + 许多 WebGL 程序在许多地方使用 `window.innerWidth` 和 `window.innerHeight`,例如: + + canvas.width = window.innerWidth; // ❌ 错误做法!! + canvas.height = window.innerHeight; // ❌ 错误做法!! + + **为什么这很糟糕:** + + 这不具备通用性。是的,对于那些你希望 canvas 填满整个屏幕的 WebGL 页面来说,它是可行的。但问题是,当你不这么做时,它就不合适了。也许你正在写一篇教程文章,canvas 只是页面中一个小图示;或者你需要一个侧边的属性编辑器,或者是一个游戏的计分面板。 + 当然你可以通过修改代码来应对这些情况,但何不一开始就写出可以适用于这些场景的代码?这样你在将这段代码拷贝到一个新项目或在旧项目中以新方式使用时,就不需要再进行调整。 + + **好的做法:** + + 与其对抗 Web 平台,不如按它的设计方式来使用它。使用 CSS 和 `clientWidth`、`clientHeight`。 + + var width = gl.canvas.clientWidth; + var height = gl.canvas.clientHeight; + + gl.canvas.width = width; + gl.canvas.height = height; + + 下面是 9 个不同场景的案例,它们都使用完全相同的代码。请注意,这些代码中 都没有引用 `window.innerWidth` 或 `window.innerHeight`。 + + 一个只包含 canvas 的页面,使用 CSS 让其全屏显示 + + 一个页面中的 canvas 设置为 70% 宽度,为编辑器控件留出空间 + + 一个将 canvas 嵌入段落中的页面 + + 一个将 canvas 嵌入段落并使用 box-sizing: border-box; 的页面 + + box-sizing: border-box; 会让边框和内边距从元素本身的尺寸中占用空间,而不是额外扩展到元素之外。换句话说,在默认的 box-sizing 模式下,一个 400x300 像素的元素加上 15 像素的边框,会得到一个内容区域为 400x300 像素、总尺寸为 430x330 像素的元素。而在 box-sizing: border-box; 模式中,边框会向内缩进,因此该元素保持 400x300 像素大小,但内容区域将缩小为 370x270 像素。 + + 这也是为什么使用 `clientWidth` 和 `clientHeight` 如此重要的又一原因。如果你设置了例如 `1em` 的边框,就无法预知 canvas 的实际渲染尺寸——不同的字体、不同的设备或浏览器都会导致 canvas 显示大小不同。 + + 一个只有容器的页面,使用 CSS 使其全屏显示,代码会在其中插入一个 canvas + + 一个容器占据页面 70% 宽度的页面,为编辑控件预留空间,代码会在其中插入一个 canvas + + 一个将容器嵌入段落中的页面,代码会在其中插入一个 canvas + + 一个使用 box-sizing: border-box; 将容器嵌入段落中的页面,代码会在其中插入一个 canvas + + 一个没有任何元素,仅通过 CSS 设置为全屏的页面,代码会在其中插入一个 canvas + + 再次强调,如果你遵循上述技术并拥抱 Web 平台的设计思路,无论遇到哪种使用场景,你都无需修改任何代码。 + +4. 使用 `'resize'` 事件来改变 canvas 的尺寸 + + 有些应用会监听窗口的 `'resize'` 事件来调整 canvas 的尺寸,比如这样: + + window.addEventListener('resize', resizeTheCanvas); + + 或者 + + window.onresize = resizeTheCanvas; + + **为什么这样不好:** + + 这并不绝对错误,但对于*大多数*WebGL 程序来说,它的适用范围较小。 + 具体来说,'resize' 事件只在窗口尺寸变化时触发。但当 canvas 因其他原因被调整大小时,它不会触发。 + 举个例子:假设你正在制作一个 3D 编辑器。左边是 canvas,右边是设置面板,你可以拖动中间的分隔条来调整设置区域的宽度。在这种情况下,canvas 的尺寸会改变,但你不会收到任何 'resize' 事件。 + 类似地,如果你的页面有其他内容被添加或移除,浏览器重新布局导致 canvas 尺寸变化,也不会触发 'resize' 事件。 + + **正确做法:** + + 就像前面提到的很多反模式一样,有一种更通用的写法可以让你的代码在大多数情况下都正常工作。 + 对于那些每一帧都在渲染的 WebGL 应用,可以在每次绘制时检查 canvas 是否需要调整大小,方法如下: + + function resizeCanvasToDisplaySize() { + var width = gl.canvas.clientWidth; + var height = gl.canvas.clientHeight; + if (gl.canvas.width != width || + gl.canvas.height != height) { + gl.canvas.width = width; + gl.canvas.height = height; + } + } + + function render() { + resizeCanvasToDisplaySize(); + drawStuff(); + requestAnimationFrame(render); + } + render(); + + 现在无论哪种情况,canvas 都会自动缩放到正确的尺寸。你无需针对不同的使用场景修改代码。 + 例如,使用上面第 3 点中相同的代码,这里是一个具有可调整大小编辑区域的编辑器示例。 + + {{{example url="../webgl-same-code-resize.html" }}} + + 这种情况下,以及所有由于页面中其他动态元素尺寸变化而导致 canvas 大小变化的场景中,都不会触发 `resize` 事件。 + + 对于不是每一帧都重绘的 WebGL 应用,以上代码依然适用,你只需要在 canvas 有可能被调整大小的场景中触发重绘即可。 + 一个简单的做法是使用 `ResizeObserver`。 + +
+    const resizeObserver = new ResizeObserver(render);
+    resizeObserver.observe(gl.canvas, {box: 'content-box'});
+    
+ +5. 向 `WebGLObject` 添加属性 + + `WebGLObject` 是指 WebGL 中的各种资源类型,比如 `WebGLBuffer` 或 `WebGLTexture` 等。 + 有些应用会给这些对象添加额外的属性。例如: + + var buffer = gl.createBuffer(); + buffer.itemSize = 3; // ❌ 不推荐的做法!! + buffer.numComponents = 75; // ❌ 不推荐的做法!! + + var program = gl.createProgram(); + ... + program.u_matrixLoc = gl.getUniformLocation(program, "u_matrix"); // ❌ 不推荐的做法!! + + **为什么这样不好:** + + 这是一个不推荐的做法,是因为 WebGL 有可能会“丢失上下文”(context lost)。 + 这种情况可能由于多种原因发生,其中最常见的原因是:如果浏览器发现 GPU 资源占用过高, + 它可能会故意让某些 `WebGLRenderingContext` 上下文失效,以释放资源。 + + 如果你希望 WebGL 程序能够稳定运行,就必须处理上下文丢失的问题。比如 Google Maps 就处理了这种情况。 + + 而上述代码的问题在于,一旦上下文丢失,像 `gl.createBuffer()` 这样的 WebGL 创建函数将返回 `null`, + 这实际上等价于以下代码: + + var buffer = null; + buffer.itemSize = 3; // ERROR! + buffer.numComponents = 75; // ERROR! + + 这很可能会让你的应用崩溃,并抛出如下错误: + + TypeError: Cannot set property 'itemSize' of null + + 虽然很多应用在上下文丢失时崩溃也无所谓,但如果以后开发者决定要支持上下文丢失的处理,那写出这种代码显然不是个好主意,因为它们迟早都得被修复。 + + **正确做法:** + + 如果你想把 `WebGLObject` 和它的相关信息绑定在一起,一个可行的方法是使用 JavaScript 对象。例如: + + var bufferInfo = { + id: gl.createBuffer(), + itemSize: 3, + numComponents: 75, + }; + + var programInfo = { + id: program, + u_matrixLoc: gl.getUniformLocation(program, "u_matrix"), + }; + + 我个人建议[使用一些简单的辅助工具,这会让编写 WebGL 代码变得更加轻松](webgl-less-code-more-fun.html)。 + +以上是我在网络上看到的一些 WebGL 反模式(Anti-Patterns)。 +希望我已经说明了为什么应当避免这些做法,并提供了简单实用的替代方案。 + +

什么是 drawingBufferWidth 和 drawingBufferHeight?

+

+GPU 对它们支持的像素矩形(纹理、渲染缓冲)的大小是有限制的。这个限制通常是大于当时常见显示器分辨率的 2 的幂。例如,如果某个 GPU 是为支持 1280x1024 的屏幕设计的,它的限制可能是 2048;如果是为 2560x1600 的屏幕设计的,则可能是 4096。 +

+这听起来很合理,但如果你有多个显示器会发生什么?假设我的 GPU 限制为 2048,但我有两个 1920x1080 的显示器。用户打开了一个 WebGL 页面,然后将窗口拉伸到两个显示器上。这时你的代码尝试将 canvas.width 设置为 canvas.clientWidth,也就是 3840。这种情况下该怎么办? +

+

我能想到只有 3 种选择:

+
    +
  1. +

    抛出异常。

    +

    这听起来很糟糕。大多数 Web 应用不会处理这个异常,结果就是程序崩溃。如果用户的数据没有保存,那就直接丢失了。

    +
  2. +
  3. +

    将 canvas 大小限制在 GPU 支持的最大值。

    +

    问题是这样也可能导致崩溃,或者页面显示错乱,因为代码以为 canvas 是它请求的大小,页面中的其他 UI 元素和布局也会依赖这个尺寸。

    +
  4. +
  5. +

    让 canvas 显示为用户请求的尺寸,但将其绘图缓冲区限制为 GPU 的最大限制。

    +

    这是 WebGL 实际采用的方案。如果代码写得正确,用户唯一会注意到的可能只是画面略微被缩放了。但总体来说一切工作正常。最坏情况下,如果 WebGL 程序没处理好,只是画面显示略微错位,等用户缩小窗口后就恢复正常了。

    +
  6. +
+

大多数人并没有多个显示器,所以这个问题很少遇到。或者说至少以前是这样。Chrome 和 Safari(至少在 2015 年 1 月)对 canvas 尺寸有硬编码的最大限制为 4096。而苹果的 5K iMac 分辨率就超过了这个限制,因此许多 WebGL 应用出现了奇怪的显示问题。同样地,越来越多人开始在多屏环境中使用 WebGL 做展示类工作,也在碰到这个限制。

+

+所以,如果你想处理这些情况,请像上面第 #1 条建议中那样使用 gl.drawingBufferWidthgl.drawingBufferHeight。对于大多数应用,只要你按照这些最佳实践来做,就能确保正常运行。但如果你的程序中需要知道绘图缓冲区的实际尺寸(比如 [拾取](webgl-picking.html),也就是将鼠标坐标转换为 canvas 像素坐标),你就需要特别注意这点。另一个例子是任何类型的后处理效果,它们也需要知道实际的绘图缓冲区大小。 +

+
diff --git a/webgl/lessons/zh_cn/webgl-attributes.md b/webgl/lessons/zh_cn/webgl-attributes.md new file mode 100644 index 000000000..6b4df4041 --- /dev/null +++ b/webgl/lessons/zh_cn/webgl-attributes.md @@ -0,0 +1,269 @@ +Title: WebGL2 属性(Attributes) +Description: WebGL 中的 attributes 是什么? +TOC: 属性(Attributes) + +本文旨在帮助你建立对 WebGL 中属性状态是如何设置的一个直观理解。 +另有[关于纹理单元的类似文章](webgl-texture-units.html)以及[framebuffer 的文章](webgl-framebuffers.html)。 + +前置知识建议阅读:[WebGL 是如何工作的](webgl-how-it-works.html) 和 +[WebGL 着色器和 GLSL](https://webglfundamentals.org/webgl/lessons/webgl-shaders-and-glsl.html)。 + +## Attributes(属性) + +在 WebGL 中,attributes 是传入顶点着色器的输入,数据来自 buffer。 +每当调用 `gl.drawArrays` 或 `gl.drawElements` 时,WebGL 会执行用户提供的顶点着色器 N 次。 +每次迭代,attributes 定义了如何从绑定到它们的 buffer 中提取数据, +并将其传递给顶点着色器中的属性变量。 + +如果用 JavaScript 来模拟实现,它们可能像这样: + +```js +// pseudo code +const gl = { + arrayBuffer: null, + vertexArray: { + attributes: [ + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, divisor: 0 }, + ], + elementArrayBuffer: null, + }, +} +``` + +如上所示,总共有 16 个 attributes。 + +当你调用 `gl.enableVertexAttribArray(location)` 或 `gl.disableVertexAttribArray`,可以将其理解为如下操作: + +```js +// pseudo code +gl.enableVertexAttribArray = function(location) { + const attrib = gl.vertexArray.attributes[location]; + attrib.enable = true; +}; + +gl.disableVertexAttribArray = function(location) { + const attrib = gl.vertexArray.attributes[location]; + attrib.enable = false; +}; +``` + +换句话说,location 就是 attribute 的索引。 + +类似地,`gl.vertexAttribPointer` 用来设置 attribute 的几乎所有其他属性。 +实现可能如下所示: + +```js +// pseudo code +gl.vertexAttribPointer = function(location, size, type, normalize, stride, offset) { + const attrib = gl.vertexArray.attributes[location]; + attrib.size = size; + attrib.type = type; + attrib.normalize = normalize; + attrib.stride = stride ? stride : sizeof(type) * size; + attrib.offset = offset; + attrib.buffer = gl.arrayBuffer; // !!!! <----- +}; +``` + +注意,调用 `gl.vertexAttribPointer` 时,`attrib.buffer` 会设置为当前的 `gl.arrayBuffer`。 +`gl.arrayBuffer` 如上述伪代码所示,通过调用 `gl.bindBuffer(gl.ARRAY_BUFFER, someBuffer)` 设置。 + + +```js +// pseudo code +gl.bindBuffer = function(target, buffer) { + switch (target) { + case ARRAY_BUFFER: + gl.arrayBuffer = buffer; + break; + case ELEMENT_ARRAY_BUFFER; + gl.vertexArray.elementArrayBuffer = buffer; + break; + ... +}; +``` + +接下来是顶点着色器。在顶点着色器中你声明属性,例如: + +```glsl +#version 300 es +in vec4 position; +in vec2 texcoord; +in vec3 normal; + +... + +void main() { + ... +} +``` + +当你使用 `gl.linkProgram(someProgram)` 将顶点着色器和片段着色器链接时, +WebGL(驱动/GPU/浏览器)会自行决定每个属性使用哪个索引(location)。 +除非你手动分配位置(见下文),否则你无法预知它们会选哪个索引。 +因此你需要通过 `gl.getAttribLocation` 查询它们: + +```js +const positionLoc = gl.getAttribLocation(program, 'position'); +const texcoordLoc = gl.getAttribLocation(program, 'texcoord'); +const normalLoc = gl.getAttribLocation(program, 'normal'); +``` + +假设 `positionLoc` = `5`,意味着在执行顶点着色器时(即调用 `gl.drawArrays` 或 `gl.drawElements`), +WebGL 期待你已经为 attribute 5 设置好了正确的 `type`、`size`、`offset`、`stride`、`buffer` 等。 + +注意:在调用 `gl.linkProgram` *之前*,你可以使用`gl.bindAttribLocation(program, location, nameOfAttribute) `指定位置,例如: + +```js +// Tell `gl.linkProgram` to assign `position` to use attribute #7 +gl.bindAttribLocation(program, 7, 'position'); +``` + +如果使用的是GLSL ES 3.00着色器,您也可以直接在着色器中指定要使用的location位置,例如: + +```glsl +layout(location = 0) in vec4 position; +layout(location = 1) in vec2 texcoord; +layout(location = 2) in vec3 normal; + +... +``` + +使用 `bindAttribLocation` 看起来更符合 [D.R.Y. 原则](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself), +不过你可以根据个人偏好选择不同的方式。 + +## 完整的属性状态 + +上面的描述中省略了一点:每个 attribute 实际上都有一个默认值。 +这在实际应用中较少使用,所以之前没有提及。 + +```js +attributeValues: [ + [0, 0, 0, 1], + [0, 0, 0, 1], + ... +], +vertexArray: { + attributes: [ + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, +  divisor: 0, }, + { enable: ?, type: ?, size: ?, normalize: ?, stride: ?, offset: ?, buffer: ?, +  divisor: 0, }, + ... +``` + +你可以通过一系列 `gl.vertexAttribXXX` 函数设置每个 attribute 的值。 +当 `enable` 为 `false` 时,会使用这些值;当 `enable` 为 `true`,则会从分配的 `缓冲区buffer` 中读取数据。 + + +## 顶点数组对象(VAO) + +```js +const vao = gl.createVertexArray(); +``` + +这会创建一个如上伪代码中 `gl.vertexArray` 所示的对象。 +调用 `gl.bindVertexArray(vao)` 将你创建的 VAO 设为当前 VAO: + +```js +// pseudo code +gl.bindVertexArray = function(vao) { + gl.vertexArray = vao ? vao : defaultVAO; +}; +``` + +这样你就可以在当前 VAO 中设置所有 `attributes` 和 `ELEMENT_ARRAY_BUFFER`。 +当你要绘制某个图形时,只需调用一次 `gl.bindVertexArray` 即可设置所有属性。 +否则你可能需要为每个属性分别调用 `gl.bindBuffer`、`gl.vertexAttribPointer`、`gl.enableVertexAttribArray`。 + +由此可见,使用 VAO 是很有价值的。 +不过,要正确使用 VAO 需要更好的组织结构。 + +举个例子,假设你想用 `gl.TRIANGLES` 和一个着色器绘制一个立方体, +再用 `gl.LINES` 和另一个着色器重新绘制它。 + +假设用三角形绘制时要做光照处理,因此顶点着色器声明了这些属性: + +```glsl +#version 300 es +// lighting-shader +// 用于绘制三角形的着色器 + +in vec4 a_position; +in vec3 a_normal; +``` + +然后使用这些位置和法线向我在[第一篇光照文章](webgl-3d-lighting-directional.html)中做的那样。 + +对于不需要光照的线条,您需要使用纯色,可以参照本教程系列[第一页](webgl-fundamentals.html)中的基础着色器实现方式。 +声明一个uniform的颜色变量。 这意味着在顶点着色器中只需处理位置数据即可 + +```glsl +#version 300 es +// solid-shader +// shader for cube with lines + +in vec4 a_position; +``` + +我们并不知道 WebGL 为每个 shader 分配的 attribute 位置是多少。 +假设 lighting-shader 的分配结果是: + +``` +a_position location = 1 +a_normal location = 0 +``` + +solid-shader只有一个attribute属性。 + +``` +a_position location = 0 +``` + +显然,在切换着色器时需要重新设置属性。 +一个着色器期望 `a_position` 的数据出现在attribute 0,另一个着色器期望它出现在attribute 1。 + +重新设置属性是一件麻烦事。更糟的是,使用 VAO 的初衷就是避免这些重复操作。 +为了解决这个问题,我们需要在链接程序之前使用 bindAttribLocation 显式指定位置: + +重新设置属性是一件麻烦事。更糟的是,使用 VAO 的初衷就是避免这些重复操作。 +为了解决这个问题,我们需要在链接程序之前使用 `bindAttribLocation` 显式指定位置。 + +我们告诉 WebGL: + +```js +gl.bindAttribLocation(solidProgram, 0, 'a_position'); +gl.bindAttribLocation(lightingProgram, 0, 'a_position'); +gl.bindAttribLocation(lightingProgram, 1, 'a_normal'); +``` + +务必在**调用 `gl.linkProgram` 之前**执行以上操作。 +这样 WebGL 在链接着色器时就会使用我们指定的位置。 +现在这两个着色器就可以使用相同的 `VAO`。 + +## 最大属性数量 + +WebGL2 要求至少支持 16 个 attribute,但具体设备 / 浏览器 / 驱动可能支持更多。 +你可以通过下面的方式获取实际支持数量: + +```js +const maxAttributes = gl.getParameter(gl.MAX_VERTEX_ATTRIBS); +``` + +如果你打算使用超过 16 个 attributes,建议检查支持数量, +并在设备不足时提示用户,或者降级使用更简单的着色器。 diff --git a/webgl/lessons/zh_cn/webgl-cross-platform-issues.md b/webgl/lessons/zh_cn/webgl-cross-platform-issues.md new file mode 100644 index 000000000..5284feb29 --- /dev/null +++ b/webgl/lessons/zh_cn/webgl-cross-platform-issues.md @@ -0,0 +1,289 @@ +Title: WebGL2 跨平台问题 +Description: Things to be aware of when trying to make your WebGL app work everywhere. +TOC: 跨平台问题 + +你也许早就知道,并不所有 WebGL 程序都能在所有设备或浏览器上运行。 + +以下是我脑海中能想到的大多数可能遇到的问题列表。 + +## 性能 + +高端 GPU 的运行速度可能是低端 GPU 的 100 倍。 +我知道的唯一解决方法是要么把目标定得低一些,要么像大多数桌面程序那样提供性能与画质的选项供用户选择。 + +## 内存 + +同样,高端 GPU 可能拥有 12 到 24 GB 的内存, +而低端 GPU 可能不到 1 GB。 +(我年纪大了,觉得“低端 = 1GB”已经很神奇了, +因为我最初是在只有 16K 到 64K 内存的机器上编程的 😜) + +## 设备限制 + +WebGL 规定了各种最低支持特性,但你本地的设备可能支持 +> 高于这个最低标准的能力,这意味着代码可能会在其他支持较少的设备上运行失败。 + +一些示例包括: + +* 允许的最大纹理尺寸 + + 2048 或 4096 被认为是比较合理的限制。至少截至 2020 年, + 看起来[99% 的设备支持 4096,但只有 50% 支持大于 4096 的尺寸](https://web3dsurvey.com/webgl/parameters/MAX_TEXTURE_SIZE)。 + + 注意:最大纹理尺寸是 GPU 可以处理的最大维度。 + 它并不意味着 GPU 有足够的内存来存储该维度平方(对于 2D 纹理)或立方(对于 3D 纹理)大小的数据。 + 例如,某些 GPU 最大尺寸为 16384,但一个每边都是 16384 的 3D 纹理将需要 16 TB 的内存!!! + +* 单个程序中支持的最大顶点属性数量 + + 在 WebGL1 中,最低支持为 8 个;在 WebGL2 中为 16 个。 + 如果你使用的数量超过这些,那么在只有最小支持能力的设备上,代码就会失败。 + +* 支持的最大 uniform 向量数量 + + 这些数量在顶点着色器和片段着色器中是分别指定的。 + + WebGL1 中,顶点着色器为 128,片段着色器为 16 + WebGL2 中,顶点着色器为 256,片段着色器为 224 + + 注意,uniform 是可以被“打包(packed)”的,上面的数字表示你可以使用的 `vec4` 数量。 + 理论上你可以使用 4 倍数量的 `float` 类型 uniform, + 但这依赖于打包算法。你可以将这个空间想象成一个 4 列的数组, + 每一行对应一个最大 uniform 向量。 + + ``` + +-+-+-+-+ + | | | | | <- one vec4 + | | | | | | + | | | | | | + | | | | | V + | | | | | max uniform vectors rows + | | | | | + | | | | | + | | | | | + ... + + ``` + + 首先会分配 `vec4`,其中一个 `mat4` 占用 4 个 `vec4`。 + 然后是将 `vec3` 填入剩余空间,接着是 `vec2`,最后是 `float`。 + 所以假设我们有:1 个 `mat4`,2 个 `vec3`,2 个 `vec2` 和 3 个 `float` + + ``` + +-+-+-+-+ + |m|m|m|m| <- the mat4 takes 4 rows + |m|m|m|m| + |m|m|m|m| + |m|m|m|m| + |3|3|3| | <- the 2 vec3s take 2 rows + |3|3|3| | + |2|2|2|2| <- the 2 vec2s can squeeze into 1 row + |f|f|f| | <- the 3 floats fit in one row + ... + + ``` + + 此外,uniform 数组总是按“垂直”方式分布的,例如如果最大允许的 uniform 向量是 16,那么你就不能拥有一个 17 元素的 `float` 数组。 + 实际上,如果你有一个 `vec4`,它将占据整整一行,也就是说只剩下 15 行,因此你最多只能拥有 15 个元素的数组。 + + 不过我的建议是:不要指望完美的打包。尽管规范中说明上面那个打包算法是必须支持的, + 但组合太多,无法测试所有驱动都正确实现了它。 + 只要你知道自己正在接近上限即可。 + + 注意:varyings 和 attributes 是无法打包的。 + +* 最大 varying 向量数 + + WebGL1 的最小值是 8,WebGL2 是 16。 + + 如果你使用的数量超过了这个限制,那么代码将在只支持最低值的设备上无法运行。 + +* 最大纹理单元数 + + 这里有三个相关值: + + 1. 一共有多少个纹理单元 + 2. 顶点着色器最多可以引用多少个纹理单元 + 3. 片段着色器最多可以引用多少个纹理单元 + + + + + + + + + + +
WebGL1WebGL2
最少存在的纹理单元数量832
顶点着色器最少可引用的纹理单元数量0!16
片段着色器最少可引用的纹理单元数量816
+ + 需要特别注意的是,WebGL1 中顶点着色器的纹理单元数量是 **0**。 + 不过这可能并不是什么致命问题。 + 显然,[大约 97% 的设备至少支持 4 个](https://web3dsurvey.com/webgl/parameters/MAX_VERTEX_TEXTURE_IMAGE_UNITS)。 + 尽管如此,你可能还是希望进行检测,以便在不兼容时提醒用户应用无法运行, + 或者退回到其他着色器方案。 + +此外还有其他一些限制。要查看这些限制,你可以使用以下参数调用 `gl.getParameter`。 + + +
+ + + + + + + + + + + + + + +
MAX_TEXTURE_SIZE 纹理的最大尺寸
MAX_VERTEX_ATTRIBS 可用的顶点属性数量
MAX_VERTEX_UNIFORM_VECTORS 顶点着色器中可用的 vec4 uniform 数量
MAX_VARYING_VECTORS 可用的 varying 数量
MAX_COMBINED_TEXTURE_IMAGE_UNITS存在的纹理单元总数
MAX_VERTEX_TEXTURE_IMAGE_UNITS 顶点着色器可引用的纹理单元数
MAX_TEXTURE_IMAGE_UNITS 片段着色器可引用的纹理单元数
MAX_FRAGMENT_UNIFORM_VECTORS 片段着色器中可用的 vec4 uniform 数量
MAX_CUBE_MAP_TEXTURE_SIZE 立方体贴图的最大尺寸
MAX_RENDERBUFFER_SIZE 渲染缓冲区的最大尺寸
MAX_VIEWPORT_DIMS 视口的最大尺寸
+
+ + +这并不是完整的列表。例如最大点大小和最大线宽也有限制,但你基本可以假设最大线宽就是 1.0,而 POINTS 通常只适用于不在意[裁剪问题](#points-lines-viewport-scissor-behavior)的简单演示。 + + +WebGL2 增加了更多限制。几个常见的如下: + +
+ + + + + + + + + + + +
MAX_3D_TEXTURE_SIZE 3D 纹理的最大尺寸
MAX_DRAW_BUFFERS 可用的颜色附件数量
MAX_ARRAY_TEXTURE_LAYERS 2D 纹理数组中的最大图层数
MAX_TRANSFORM_FEEDBACK_SEPARATE_ATTRIBS 使用 transform feedback 时可输出到独立缓冲区的 varying 数量
MAX_TRANSFORM_FEEDBACK_INTERLEAVED_COMPONENTS当输出到单个缓冲区时可输出的 varying 总数
MAX_COMBINED_UNIFORM_BLOCKS 所有着色器中可使用的 uniform block 总数
MAX_VERTEX_UNIFORM_BLOCKS 顶点着色器中可用的 uniform block 数量
MAX_FRAGMENT_UNIFORM_BLOCKS 片段着色器中可用的 uniform block 数量
+
+ +## 深度缓冲区分辨率 + +一些非常老旧的移动设备使用 16 位深度缓冲区。除此之外,据我所知,99% 的设备都使用 24 位深度缓冲区, +所以你大概率无需担心这个问题。 + +## readPixels 的 format/type 组合 + +只有某些格式/类型组合是强制支持的,其他组合是可选的。 +这个问题在[这篇文章](webgl-readpixels.html)中有详细介绍。 + +## framebuffer 附件组合 + +帧缓冲可以附加一个或多个纹理和渲染缓冲对象作为附件。 + +在 WebGL1 中,只有以下三种附件组合是被保证支持的: + +1. 将格式为RGBA、类型为UNSIGNED_BYTE的纹理附加为COLOR_ATTACHMENT0 +2. 将格式为RGBA、类型为UNSIGNED_BYTE的纹理附加为COLOR_ATTACHMENT0,同时将格式为DEPTH_COMPONENT的渲染缓冲区附加为DEPTH_ATTACHMENT +3. 将格式为RGBA、类型为UNSIGNED_BYTE的纹理附加为COLOR_ATTACHMENT0,同时将格式为DEPTH_STENCIL的渲染缓冲区附加为DEPTH_STENCIL_ATTACHMENT + +所有其他组合都由具体实现决定是否支持。你可以通过调用 +`gl.checkFramebufferStatus` 并检查是否返回 `FRAMEBUFFER_COMPLETE` 来验证支持情况。 + +WebGL2 保证可以写入更多格式,但依然存在**任何组合都有可能失败!**的限制。 +如果你附加了多个颜色附件,最稳妥的方法是确保它们都使用相同的格式。 + +## 扩展(Extensions) + +WebGL1 和 WebGL2 中的许多功能都是可选的。 +`getExtension` 这个 API 的意义就在于它可能失败(如果扩展不存在), +所以你应该检查它是否返回了有效扩展,而不是盲目假设它总能成功。 + +在 WebGL1 和 WebGL2 中,最常见缺失的扩展之一是 +`OES_texture_float_linear`,它允许对浮点纹理进行过滤, +也就是说可以把 `TEXTURE_MIN_FILTER` 和 `TEXTURE_MAG_FILTER` +设置为除 `NEAREST` 之外的值。很多移动设备并不支持这个扩展。 + +在 WebGL1 中另一个常缺失的扩展是 `WEBGL_draw_buffers`, +这个扩展允许将多个颜色附件绑定到一个帧缓冲上。 +在桌面平台上支持率大约是 70%,而在智能手机上几乎没有支持(虽然这听起来不太对)。 +基本上,任何能运行 WebGL2 的设备应该也支持 WebGL1 的 `WEBGL_draw_buffers`, +但这显然仍然是个潜在问题。 +如果你需要一次性渲染到多个纹理,很可能你的网站就是为高端 GPU 设计的。 +不过你仍应检测用户设备是否支持,并在不支持时给出友好的提示说明。 + +对于 WebGL1,以下 3 个扩展几乎被所有设备支持, +所以即使你希望在缺失时警告用户页面无法正常运行, +这些用户通常是极其老旧的设备,原本也跑不动你的页面: + +- `ANGLE_instanced_arrays`:支持[实例化绘制](webgl-instanced-drawing.html) +- `OES_vertex_array_object`:支持将所有 attribute 状态存入对象中, + 从而通过一次函数调用切换所有状态,见 [这里](webgl-attributes.html) +- `OES_element_index_uint`:允许使用 `UNSIGNED_INT` 类型的 32 位索引, + 与 [`drawElements`](webgl-indexed-vertices.html) 配合使用 + +## attribute 位置(attribute locations) + +一个较常见的 bug 是没有正确获取 attribute 的位置。 + +例如你有一个顶点着色器如下: + +```glsl +attribute vec4 position; +attribute vec2 texcoord; + +uniform mat4 matrix; + +varying vec2 v_texcoord; + +void main() { + gl_Position = matrix * position; + v_texcoord = texcoord; +} +``` + +你的代码假设 `position` 是 attribute 0,`texcoord` 是 attribute 1, +但这是**没有保证的**。所以它在你这能运行,在别人那可能就失败了。 +这类问题往往是因为你没有明确这么指定位置, +但由于某些代码错误,恰好在你这里按预期的方式分配了位置。 + +有三种解决方案: + +1. 始终使用 `gl.getAttribLocation` 显式查询位置 +2. 在调用 `gl.linkProgram` 之前,使用 `gl.bindAttribLocation` 显式绑定位置 +3. 仅限 WebGL2:可以直接在 shader 中设置 attribute 的位置,例如: + + ```glsl + #version 300 es + layout(location = 0) vec4 position; + latout(location = 1) vec2 texcoord; + ... + ``` + + 方案 2 看起来是最符合 [D.R.Y. 原则](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) 的, + 而方案 3 则是最 [W.E.T.(重复)](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself#DRY_vs_WET_solutions) 的—— + 除非你是在运行时生成 shader。 + +## GLSL 未定义行为 + +一些 GLSL 函数具有未定义行为。例如,当 `x < 0` 时,`pow(x, y)` 的结果是未定义的。 +更详细的列表见[这篇关于聚光灯照明的文章底部](webgl-3d-lighting-spot.html)。 + +## Shader 精度问题 + +截至 2020 年,这方面最大的问题是: +如果你在着色器中使用了 `mediump` 或 `lowp`,在桌面端 GPU 实际会使用 `highp`, +但在移动设备上它们真的就是 `mediump` 或 `lowp`。 +这意味着你在桌面开发时可能不会发现任何问题。 + +详细内容见[这篇文章](webgl-precision-issues.html)。 + +## 点、线、视口和剪裁行为 + +在 WebGL 中,`POINTS` 和 `LINES` 的最大尺寸可能就是 1, +实际上对于 `LINES` 来说,这已成为最常见的限制。 +另外,当点的中心在视口外时是否会被裁剪,是由实现决定的, +见[这篇文章底部](webgl-drawing-without-data.html#pointissues)。 + +类似地,视口是否只裁剪顶点、还是同时裁剪像素,也是未定义的。 +但剪裁测试(scissor test)始终裁剪像素。 +因此,如果你设置了比目标区域更小的视口,并且正在绘制 LINES 或 POINTS, +你应该开启剪裁测试并设置合适的剪裁区域。 diff --git a/webgl/lessons/zh_cn/webgl-framebuffers.md b/webgl/lessons/zh_cn/webgl-framebuffers.md new file mode 100644 index 000000000..bd486a104 --- /dev/null +++ b/webgl/lessons/zh_cn/webgl-framebuffers.md @@ -0,0 +1,133 @@ +Title: WebGL2 帧缓冲区(Framebuffers) +Description: WebGL 中的 帧缓冲区(Framebuffers) 是什么? +TOC: 帧缓冲区(Framebuffers) + +本文旨在帮助你建立对 WebGL 中 framebuffer 的一个直观理解。 +framebuffer 之所以重要,是因为它们允许你[渲染到纹理](webgl-render-to-texture.html)。 + +一个 `Framebuffer` 其实就是一个**附件的集合(attachments)**。就是这样! +它的作用是让你可以将内容渲染到`纹理`或`渲染缓冲区`中。 + +你可以将一个 Framebuffer 对象想象成这样: + +``` +class Framebuffer { + constructor() { + this.attachments = new Map(); // attachments by attachment point + this.drawBuffers = [gl.BACK, gl.NONE, gl.NONE, gl.NONE, ...]; + this.readBuffer = gl.BACK, + } +} +``` + +而 `WebGL2RenderingContext`(也就是 `gl` 对象)可以理解为如下结构: + +```js +// pseudo code +gl = { + drawFramebufferBinding: defaultFramebufferForCanvas, + readFramebufferBinding: defaultFramebufferForCanvas, +} +``` + +这里有两个绑定点(binding point),设置方式如下: + +```js +gl.bindFramebuffer(target, framebuffer) { + framebuffer = framebuffer || defaultFramebufferForCanvas; // if null use canvas + switch (target) { + case: gl.DRAW_FRAMEBUFFER: + this.drawFramebufferBinding = framebuffer; + break; + case: gl.READ_FRAMEBUFFER: + this.readFramebufferBinding = framebuffer; + break; + case: gl.FRAMEBUFFER: + this.drawFramebufferBinding = framebuffer; + this.readFramebufferBinding = framebuffer; + break; + default: + ... error ... + } +} +``` + +`DRAW_FRAMEBUFFER`:用于向 `framebuffer` 绘制内容,如通过 `gl.clear`、`gl.draw???` 或 `gl.blitFramebuffer`。 +`READ_FRAMEBUFFER`:用于从 `framebuffer` 中读取内容,如通过 `gl.readPixels` 或 `gl.blitFramebuffer`。 + +你可以通过三个函数向 framebuffer 添加附件,`framebufferTexture2D`、 +`framebufferRenderbuffer` 和 `framebufferTextureLayer`。 + +它们的内部逻辑可以想象成如下实现: + +```js +// pseudo code +gl._getFramebufferByTarget(target) { + switch (target) { + case gl.FRAMEBUFFER: + case gl.DRAW_FRAMEBUFFER: + return this.drawFramebufferBinding; + case gl.READ_FRAMEBUFFER: + return this.readFramebufferBinding; + } +} +gl.framebufferTexture2D(target, attachmentPoint, texTarget, texture, mipLevel) { + const framebuffer = this._getFramebufferByTarget(target); + framebuffer.attachments.set(attachmentPoint, { + texture, texTarget, mipLevel, + }); +} +gl.framebufferTextureLayer(target, attachmentPoint, texture, mipLevel, layer) { + const framebuffer = this._getFramebufferByTarget(target); + framebuffer.attachments.set(attachmentPoint, { + texture, texTarget, mipLevel, layer + }); +} +gl.framebufferRenderbuffer(target, attachmentPoint, renderbufferTarget, renderbuffer) { + const framebuffer = this._getFramebufferByTarget(target); + framebuffer.attachments.set(attachmentPoint, { + renderbufferTarget, renderbuffer + }); +} +``` + +你可以使用 `gl.drawBuffers` 设置 `framebuffer` 的绘制目标数组,其内部实现如下所示: + + +```js +// pseudo code +gl.drawBuffers(drawBuffers) { + const framebuffer = this._getFramebufferByTarget(gl.DRAW_FRAMEBUFFER); + for (let i = 0; i < maxDrawBuffers; ++i) { + framebuffer.drawBuffers[i] = i < drawBuffers.length + ? drawBuffers[i] + : gl.NONE + } +} +``` + +`drawBuffers` 数组决定了哪些附件会被渲染。 + +合法值包括: + +* `gl.NONE`:不渲染到这个附件 +* `gl.COLOR_ATTACHMENTx`:其中 `x` 和附件索引一样 +* `gl.BACK`:仅在当前 `framebuffer` 为 `null` 时有效,表示渲染到默认 canvas 的 `backbuffer` + +你还可以使用 `gl.readBuffer` 设置读缓冲: + +```js +// pseudo code +gl.readBuffer(readBuffer) { + const framebuffer = this._getFramebufferByTarget(gl.READ_FRAMEBUFFER); + framebuffer.readBuffer = readBuffer; +} +``` + +readBuffer 决定在调用 `gl.readPixels` 时会从哪个附件读取。 + +重点总结:framebuffer 本质上只是一个附件的简单集合。 +真正复杂的是这些附件之间的限制与兼容性。 +例如:浮点纹理附件默认不能被渲染,除非通过扩展如 `EXT_color_buffer_float` 开启支持。 +此外,如果 framebuffer 包含多个附件,它们必须具有相同的尺寸。 + diff --git a/webgl/lessons/zh_cn/webgl-matrix-vs-math.md b/webgl/lessons/zh_cn/webgl-matrix-vs-math.md new file mode 100644 index 000000000..2c595782d --- /dev/null +++ b/webgl/lessons/zh_cn/webgl-matrix-vs-math.md @@ -0,0 +1,231 @@ +Title: WebGL2中的矩阵vs数学中的矩阵 +Description: WebGL约定与数学约定之间的差异。 +TOC: WebGL2中的矩阵vs数学中的矩阵 + + +这篇文章是对若干讨论矩阵的文章的补充说明,尤其包括:[介绍矩阵的文章](webgl-2d-matrices.html), +以及 [介绍3D的文章](webgl-3d-orthographic.html)、[透视投影的文章](webgl-3d-perspective.html) 和 [摄像机相关的文章](webgl-3d-camera.html)。 + +在编程中,通常“行”是从左到右的,“列”是从上到下的。 + +> ## col·umn +> /ˈkäləm/ +> +> *名词* +> 1. 一个直立的柱子,通常是圆柱形,由石头或混凝土制成,用于支撑檐部、拱门或其他结构,或作为独立的纪念碑而存在。 +> +> *同义词*: 柱子、柱杆、杆、立柱、垂直物体、... +> +> 2. 一页或一段文字的垂直分栏。 + +> ## row +> /rō/ +> +> *名词* +> * 表格中一行水平排列的条目。 + +我们可以在各种软件中看到这些术语的使用。例如,我使用的文本编辑器中显示了“行(Lines)”和“列(columns)”, +在这种情况下,“行”就是“row”的另一种说法,因为“column”这个词已经被用于表示垂直方向了。 + +
+ +请注意左下角区域,状态栏中显示了当前的行(line)和列(column)。 + +在电子表格软件中,我们可以看到行是横向排列的。 + +
+ +而列是纵向排列的。 + +
+ +因此,当我们在 JavaScript 中为 WebGL 创建一个 3x3 或 4x4 的矩阵时,我们这样写: + +```js +const m3x3 = [ + 0, 1, 2, // row 0 + 3, 4, 5, // row 1 + 6, 7, 8, // row 2 +]; + +const m4x4 = [ + 0, 1, 2, 3, // row 0 + 4, 5, 6, 7, // row 1 + 8, 9, 10, 11, // row 2 + 12, 13, 14, 15, // row 3 +]; +``` + +根据上述惯例,`3x3`矩阵的第一行是 `0, 1, 2`,而 `4x4` 矩阵的最后一行是 `12, 13, 14, 15`。 + +正如我们在[矩阵](webgl-2d-matrices.html)中看到的,要制作一个相当标准的 WebGL 3x3 二维平移矩阵,平移值 `tx` 和 `ty` 位于位置 6 和 7。 + + +```js +const some3x3TranslationMatrix = [ + 1, 0, 0, + 0, 1, 0, + tx, ty, 1, +]; +``` + +或者在[3D基础](webgl-3d-orthographic.html)中介绍的 4x4 矩阵中,平移值(tx, ty, tz)位于第 12、13、14 的位置。 + +```js +const some4x4TranslationMatrix = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + tx, ty, tz, 1, +]; +``` + +但这里有一个问题:数学中的矩阵运算通常按列优先(column-major)的惯例来书写。数学家会这样表示一个 3x3 的平移矩阵: + +
+ +以及一个 4x4 的平移矩阵如下: + +
+ +这样就留下了一个问题。如果我们想让矩阵看起来像数学中的矩阵, +我们可能尝试这样写 4x4 的矩阵: + +```js +const some4x4TranslationMatrix = [ + 1, 0, 0, tx, + 0, 1, 0, ty, + 0, 0, 1, tx, + 0, 0, 0, 1, +]; +``` + +不幸的是,这样做会有问题。正如[摄像机一文](webgl-3d-camera.html)中提到的, +4x4 矩阵的每一列通常都有特定含义。 + +第一、第二和第三列通常分别表示 x、y 和 z 轴,而最后一列表示位置或平移。 + +问题是,在代码中单独获取这些部分会很麻烦。 +想要获取 Z 轴?你得这样写: + + +```js +const zAxis = [ + some4x4Matrix[2], + some4x4Matrix[6], + some4x4Matrix[10], +]; +``` + +唉! + +WebGL(以及它所基于的OpenGL ES)的解决方案居然是——把‘行’硬说成‘列’。 + +```js +const some4x4TranslationMatrix = [ + 1, 0, 0, 0, // this is column 0 + 0, 1, 0, 0, // this is column 1 + 0, 0, 1, 0, // this is column 2 + tx, ty, tz, 1, // this is column 3 +]; +``` + +现在它就符合数学定义了。对比上面的例子,如果我们想要获取 Z 轴,只需要做: + +```js +const zAxis = some4x4Matrix.slice(8, 11); +``` + +对于熟悉 C++ 的人来说,OpenGL 本身要求一个 4x4 矩阵的 16 个值在内存中是连续的,因此在 C++ 中我们可以创建一个 `Vec4` 结构体或类: + +```c++ +// C++ +struct Vec4 { + float x; + float y; + float z; + float w; +}; +``` + +然后我们可以用这四个 `Vec4` 来创建一个 4x4 矩阵: + +```c++ +// C++ +struct Mat4x4 { + Vec4 x_axis; + Vec4 y_axis; + Vec4 z_axis; + Vec4 translation; +} +``` + +或者直接写成 + +```c++ +// C++ +struct Mat4x4 { + Vec4 column[4]; +} +``` + +看似这样就能正常工作。 + +但遗憾的是,当你在代码中真正静态声明一个矩阵时,它的形式与数学中的矩阵表示法相去甚远。 + +```C++ +// C++ +Mat4x4 someTranslationMatrix = { + { 1, 0, 0, 0, }, + { 0, 1, 0, 0, }, + { 0, 0, 1, 0, }, + { tx, ty, tz, 1, }, +}; +``` + +回到 JavaScript 环境(我们通常没有类似 C++ 结构体的数据结构),情况就有所不同。 + +```js +const someTranslationMatrix = [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + tx, ty, tz, 1, +]; +``` + +因此,采用将“行”称为“列”的这种约定,有些事情会变得更简单,但如果你是数学背景的人,可能会觉得更困惑。 + +我提到这些是因为这些文章是从程序员的视角写的,而不是数学家的视角。这意味着就像其他被当作二维数组使用的一维数组一样,行是横向排列的。 + + +```js +const someTranslationMatrix = [ + 1, 0, 0, 0, // row 0 + 0, 1, 0, 0, // row 1 + 0, 0, 1, 0, // row 2 + tx, ty, tz, 1, // row 3 +]; +``` + +就像 + +```js +// happy face image +const dataFor7x8OneChannelImage = [ + 0, 255, 255, 255, 255, 255, 0, // row 0 + 255, 0, 0, 0, 0, 0, 255, // row 1 + 255, 0, 255, 0, 255, 0, 255, // row 2 + 255, 0, 0, 0, 0, 0, 255, // row 3 + 255, 0, 255, 0, 255, 0, 255, // row 4 + 255, 0, 255, 255, 255, 0, 255, // row 5 + 255, 0, 0, 0, 0, 0, 255, // row 6 + 0, 255, 255, 255, 255, 255, 0, // row 7 +] +``` + +所以这些文章会把它们称作“行”。 + +如果你是数学专业出身,可能会觉得有些困惑。很抱歉,我没有更好的解决方案。我本可以把明显的第3行称作“列”,但那样也会让人迷惑,因为这不符合其他编程语言的习惯。 + +无论如何,希望这些解释能帮助你理解为什么文中的说明看起来不像数学书里的内容,而更像代码,且遵循了编程中的惯例。希望这能帮助你搞清楚其中的原理,也不会让习惯数学规范的人觉得太难理解。 diff --git a/webgl/lessons/zh_cn/webgl-precision-issues.md b/webgl/lessons/zh_cn/webgl-precision-issues.md new file mode 100644 index 000000000..4b566658e --- /dev/null +++ b/webgl/lessons/zh_cn/webgl-precision-issues.md @@ -0,0 +1,227 @@ +Title: WebGL2 精度问题 +Description: WebGL2里的各种精度问题 +TOC: 精度问题 + +本文讨论 WebGL2 中的各种精度问题。 + +## `lowp`, `mediump`, `highp` + +在[本站的第一篇文章](webgl-fundamentals.html)中,我们创建了顶点着色器和片段着色器。 +在创建片段着色器时,顺带提到片段着色器没有默认的精度,所以我们需要通过添加这行代码来设置。 + +```glsl +precision highp float; +``` + +这到底是怎么回事? + +`lowp`、 `mediump` 和 `highp`是精度设置。 +这里的精度实际上指的是用多少位(bit)来存储一个值。 +JavaScript 中的数字使用 64 位,大多数 WebGL 中的数字只有 32 位。 +位数越少意味着速度越快,位数越多意味着精度越高和/或范围越大。 + +我不确定自己是否能解释清楚。 +你可以搜索[double vs float](https://www.google.com/search?q=double+vs+float) +了解更多精度问题的示例,但一种简单的理解方法是将其比作字节和短整型,或者在 JavaScript 中的 Uint8Array 和 Uint16Array 的区别。 + +* Uint8Array 是一个无符号 8 位整数数组。8 位能表示 28(256)个数,范围是 0 到 255。 +* Uint16Array 是一个无符号 16 位整数数组。16 位能表示 216(65536)个数,范围是 0 到 65535。 +* Uint32Array 是一个无符号 32 位整数数组。32 位能表示 232(约42亿)个数,范围是 0 到 4294967295。 + +`lowp`、`mediump` 和 `highp` 也是类似的概念。 + +* `lowp` 至少是 9 位。对于浮点数,其值范围大致是 -2 到 +2,整数则类似于 `Uint8Array` 或 `Int8Array`。 +* `mediump` 至少是 16 位。对于浮点数,其值范围大致是 -214 到 +214,整数类似于 `Uint16Array` 或 `Int16Array`。 +* `highp` 至少是 32 位。对于浮点数,其值范围大致是 -262 到 +262,整数类似于 `Uint32Array` 或 `Int32Array`。 + +需要注意的是,并非范围内的所有数值都能被表示。 +最容易理解的是 `lowp`,它只有 9 位,因此只能表示 512 个唯一值。 +虽然它的范围是 -2 到 +2,但在这之间有无限多个值,比如 1.9999999 和 1.999998,这两个数值都不能被 `lowp` 精确表示。 +例如,如果你用 `lowp` 做颜色计算,可能会出现色带现象。颜色范围是 0 到 1,而 lowp 在 0 到 1 之间大约只有 128 个可表示值。 +这意味着如果你想加一个非常小的值(比如 1/512),它可能根本不会改变数值,因为无法被表示,实际上就像加了 0。 + +理论上,我们可以在任何地方使用 `highp` 完全避免这些问题,但在实际设备上,使用 `lowp` 和 `mediump` 通常会比 `highp` 快很多,有时甚至显著更快。 + +还有一点,和 `Uint8Array`、`Uint16Array` 不同的是,`lowp`、`mediump`、`highp` 允许在内部使用更高的精度(更多位)。 +例如,在桌面 GPU 上,如果你在着色器中写了 `mediump`,它很可能仍然使用 32 位精度。 +这导致在开发时很难测试 `lowp` 或 `mediump` 的真正表现。 +要确认你的着色器在低精度设备上能正常工作,必须在实际使用较低精度的设备上测试。 + +如果你想用 `mediump` 以提高速度,常见问题包括比如点光源的高光计算,它在世界空间或视图空间传递的值可能超出 `mediump` 的范围。 +可能在某些设备上你只能舍弃高光计算。下面是将[点光源](webgl-3d-lighting-point.html)示例的片段着色器改为 `mediump` 的代码示例: + +```glsl +#version 300 es + +-precision highp float; ++precision mediump float; + +// Passed in and varied from the vertex shader. +in vec3 v_normal; +in vec3 v_surfaceToLight; +in vec3 v_surfaceToView; + +uniform vec4 u_color; +uniform float u_shininess; + +// we need to declare an output for the fragment shader +out vec4 outColor; + +void main() { + // because v_normal is a varying it's interpolated + // so it will not be a uint vector. Normalizing it + // will make it a unit vector again + vec3 normal = normalize(v_normal); + + vec3 surfaceToLightDirection = normalize(v_surfaceToLight); +- vec3 surfaceToViewDirection = normalize(v_surfaceToView); +- vec3 halfVector = normalize(surfaceToLightDirection + surfaceToViewDirection); + + // compute the light by taking the dot product + // of the normal to the light's reverse direction + float light = dot(normal, surfaceToLightDirection); +- float specular = 0.0; +- if (light > 0.0) { +- specular = pow(dot(normal, halfVector), u_shininess); +- } + + outColor = u_color; + + // Lets multiply just the color portion (not the alpha) + // by the light + outColor.rgb *= light; + +- // Just add in the specular +- outColor.rgb += specular; +} +``` + +注意:即便如此还不够。在顶点着色器中我们有以下代码: + +```glsl + // compute the vector of the surface to the light + // and pass it to the fragment shader + v_surfaceToLight = u_lightWorldPosition - surfaceWorldPosition; +``` + +假设光源距离表面有 1000 个单位。 +然后我们进入片段着色器,执行这一行代码: + +```glsl + vec3 surfaceToLightDirection = normalize(v_surfaceToLight); +``` + +看起来似乎没问题。除了归一化向量的常规方法是除以其长度,而计算长度的标准方式是: + + +``` + float length = sqrt(v.x * v.x + v.y * v.y * v.z * v.z); +``` + +如果 x、y 或 z 中的某一个值是 1000,那么 1000×1000 就是 1000000。 +而 1000000 超出了 `mediump` 的表示范围。 + +这里的一个解决方案是在顶点着色器中进行归一化(normalize)。 + +``` + // compute the vector of the surface to the light + // and pass it to the fragment shader +- v_surfaceToLight = u_lightWorldPosition - surfaceWorldPosition; ++ v_surfaceToLight = normalize(u_lightWorldPosition - surfaceWorldPosition); +``` + +现在赋值给 `v_surfaceToLight` 的数值范围在 -1 到 +1 之间,这正好落在 `mediump` 的有效范围内。 + +请注意,在顶点着色器中进行归一化实际上不会得到完全相同的结果,但结果可能足够接近,以至于除非并排对比,否则没人会注意到差异。 + +像 `normalize`、`length`、`distance`、`dot` 这样的函数都会面临一个问题:如果参与计算的值过大,那么在 `mediump` 精度下就可能超出其表示范围。 + +不过,你实际上需要在一个 `mediump` 为 16 位的设备上进行测试。在桌面设备上,`mediump` 实际上使用的是与 `highp` 相同的 32 位精度,因此任何相关的问题在桌面上都不会显现出来。 + +## 检测对16位 `mediump` 的支持 + +你调用 `gl.getShaderPrecisionFormat`,传入着色器类型(`VERTEX_SHADER` 或 `FRAGMENT_SHADER`),以及以下精度类型之一: + +- `LOW_FLOAT` +- `MEDIUM_FLOAT` +- `HIGH_FLOAT` +- `LOW_INT` +- `MEDIUM_INT` +- `HIGH_INT` + +它会[返回精度信息]。 + +{{{example url="../webgl-precision-lowp-mediump-highp.html"}}} + +`gl.getShaderPrecisionFormat` 会返回一个对象,包含三个属性:`precision`、`rangeMin` 和 `rangeMax`。 + +对于 `LOW_FLOAT` 和 `MEDIUM_FLOAT`,如果它们实际上就是 `highp`,那么 `precision` 将是 23。否则,它们通常分别是 8 和 15,或者至少会小于 23。对于 `LOW_INT` 和 `MEDIUM_INT`,如果它们等同于 `highp`,那么 `rangeMin` 会是 31。如果小于 31,则说明例如 `mediump int` 比 `highp int` 更高效。 + +我的 Pixel 2 XL 对于 `mediump` 和 `lowp` 都使用 16 位。我不确定自己是否用过使用 9 位表示 `lowp` 的设备,因此也不清楚在这种情况下通常会遇到哪些问题。 + +在本文系列中,我们在片段着色器中通常会指定默认精度。我们也可以为每个变量单独指定精度,例如: + + +```glsl +uniform mediump vec4 color; // a uniform +in lowp vec4 normal; // an attribute or varying input +out lowp vec4 texcoord; // a fragment shader output or varying output +lowp float foo; // a variable +``` + +## 纹理格式 + +纹理是规范中另一个指出“实际使用的精度可能高于请求精度”的地方。 + +例如,你可以请求一个每通道 4 位、总共 16 位的纹理,像这样: + +``` +gl.texImage2D( + gl.TEXTURE_2D, // target + 0, // mip level + gl.RGBA4, // internal format + width, // width + height, // height + 0, // border + gl.RGBA, // format + gl.UNSIGNED_SHORT_4_4_4_4, // type + null, +); +``` + +但实现上实际上可能在内部使用更高分辨率的格式。 +我认为大多数桌面端会这样做,而大多数移动端 GPU 不会。 + +我们可以做个测试。首先我们会像上面那样请求一个每通道 4 位的纹理。 +然后我们会通过渲染一个 0 到 1 的渐变来[渲染到它](webgl-render-to-texture.html)。 + +接着我们会将该纹理渲染到画布上。如果纹理内部确实是每通道 4 位, +那么从我们绘制的渐变中只会有 16 个颜色级别。 +如果纹理实际上是每通道 8 位,我们将看到 256 个颜色级别。 + +{{{example url="../webgl-precision-textures.html"}}} + +在我的智能手机上运行时,我看到纹理使用的是每通道4位 +(至少红色通道是4位,因为我没有测试其他通道)。 + +
+ +而在我的桌面上,我看到纹理实际上使用的是每通道8位, +尽管我只请求了4位。 + +
+ +需要注意的一点是,WebGL 默认会对结果进行抖动处理, +使这种渐变看起来更平滑。你可以通过以下方式关闭抖动: + + +```js +gl.disable(gl.DITHER); +``` + +如果我不关闭抖动处理,那么我的智能手机会产生这样的效果。 + +
+ +就我目前所知,这种情况通常只会在以下特定场景出现:当开发者将某种低比特精度的纹理格式用作渲染目标,却未在实际采用该低分辨率的设备上进行测试时。 +若仅通过桌面端设备进行测试,由此引发的问题很可能无法被发现。 diff --git a/webgl/lessons/zh_cn/webgl-readpixels.md b/webgl/lessons/zh_cn/webgl-readpixels.md new file mode 100644 index 000000000..962d9610a --- /dev/null +++ b/webgl/lessons/zh_cn/webgl-readpixels.md @@ -0,0 +1,33 @@ +Title: WebGL2 readPixels +Description: readPixels 的详细信息 +TOC: readPixels + + +在 WebGL 中,调用 `readPixels` 时需要传入一个 format/type(格式/类型)对。对于一个给定的纹理内部格式(附加到帧缓冲上的纹理),只有两种 format/type 组合是合法的。 + +来自规范的说明: + +> 对于标准化的定点渲染表面,接受格式是 `RGBA` 和类型 `UNSIGNED_BYTE`的组合。 +> 对于有符号整数渲染表面,接受的格式是 `RGBA_INTEGER` 和类型 `INT`的组合。 +> 对于无符号整数渲染表面,接受的格式是 `RGBA_INTEGER` 和类型 `UNSIGNED_INT`的组合。 + +第二种组合是实现定义的 +这很可能意味着,如果你想让你的代码具有可移植性,就不应该在 WebGL 中使用它。 +你可以通过查询以下内容来查看当前实现所支持的 format/type 组合: + +```js +// 假设已经绑定了一个附有要读取纹理的 framebuffer +const format = gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_FORMAT); +const type = gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_TYPE); +``` + +还需要注意,哪些纹理格式是 可渲染 的(即你可以将它们附加到 framebuffer 并对其进行渲染) 在某种程度上也是实现定义的。 +WebGL2 列出了[许多格式](webgl-data-textures.html),但其中有些是可选的(例如 `LUMINANCE`), +而有些则默认不可渲染,除非通过扩展启用(例如 `RGBA32F`)。 + +**下表是实时的**。你可能会注意到,它在不同的设备、操作系统、GPU 甚至浏览器上可能会给出不同的结果。 +我在自己的机器上发现 Chrome 和 Firefox 在某些实现定义值上的表现是不同的。 + +
+ + diff --git a/webgl/lessons/zh_cn/webgl-references.md b/webgl/lessons/zh_cn/webgl-references.md new file mode 100644 index 000000000..0cf7fb375 --- /dev/null +++ b/webgl/lessons/zh_cn/webgl-references.md @@ -0,0 +1,57 @@ +Title: 参考资料 +Description: 其他参考资料 +TOC: 参考资料 + +以下是可能会对你有用的一些资源链接: + +## 教程和课程 + +* [3d game shaders for beginners](https://lettier.github.io/3d-game-shaders-for-beginners/) + 提供了许多图形技术的精彩讲解。虽然是基于 OpenGL,但讲解图文并茂,因此应当可以较容易地将其适配为 WebGL。 + +* [Learn OpenGL](https://learnopengl.com/): 现代 OpenGL 教程 + + 这些内容可能有用,也可能不适用。尽管 API 相似,但 OpenGL 并不是 WebGL。首先,OpenGL 是一个基于 C 的库;其次,OpenGL 拥有比 WebGL 更多的特性,而且着色器语言也有许多差异。尽管如此,教程中展示的很多概念和技术在 WebGL 中依然同样适用。 + +## 工具 / 浏览器扩展 + +* [Spector](https://chrome.google.com/webstore/detail/spectorjs/denbgaamihkadbghdceggmchnflmhpmk?hl=en): 一个可以显示所有 WebGL 调用的浏览器扩展 + +* [Shader Editor](https://chrome.google.com/webstore/detail/shader-editor/ggeaidddejpbakgafapihjbgdlbbbpob?hl=en): 一个允许你在实时网页中查看和编辑着色器的浏览器扩展 + +* [WebGL Insight](https://chrome.google.com/webstore/detail/webgl-insight/djdcbmfacaaocoomokenoalbomllhnko?hl=en): 一个用于查看 WebGL 使用情况的扩展 + +* [webgl-helpers](https://greggman.github.io/webgl-helpers/): 用于辅助 WebGL 编程的脚本集合 + +## 库 + +* [twgl](https://twgljs.org): 一个帮助减少 WebGL 冗长代码的库 + +* [three.js](https://threejs.org): 最流行的 JavaScript 3D 图形库 + +* [PlayCanvas](https://playcanvas.com/): 一个带有游戏编辑器的 WebGL 游戏引擎 + +* [regl](https://regl.party/): 一个无状态函数式的 WebGL 库 + +## 规范 + +* [WebGL2](https://www.khronos.org/registry/webgl/specs/latest/2.0/): WebGL2 的规范 + +* [OpenGL ES 3.0](https://www.khronos.org/registry/OpenGL/specs/es/3.0/es_spec_3.0.pdf): WebGL2 所基于的 OpenGL ES 3.0 规范 + +* [GLSL ES 3.0](https://www.khronos.org/registry/OpenGL/specs/es/3.0/GLSL_ES_Specification_3.00.pdf): WebGL2 使用的着色器语言规范 + +## 趣味网站 + +* [Shadertoy.com](https://shadertoy.com): 在极限条件下创作的令人惊叹的片段着色器 + + ⚠️ 注意:shadertoy.com 上的着色器通常并不是生产级 WebGL 应用中使用的那种。但其中仍蕴含许多可借鉴的技术与灵感。 + +* [glslsandbox.com](https://glslsandbox.com): 最早的片段着色器在线实验平台 + +* [vertexshaerart.com](https://vertexshaderart.com): glslsandbox 的顶点着色器版本 + +--- + +如果你还知道其他一些不错的参考资源,欢迎 +[提交一个 issue](https://github.com/gfxfundamentals/webgl-fundamentals/issues) 来补充。 diff --git a/webgl/lessons/zh_cn/webgl-texture-units.md b/webgl/lessons/zh_cn/webgl-texture-units.md new file mode 100644 index 000000000..667cdee71 --- /dev/null +++ b/webgl/lessons/zh_cn/webgl-texture-units.md @@ -0,0 +1,131 @@ +Title: WebGL2 纹理单元(Texture Units) +Description: WebGL 中的纹理单元是什么? +TOC: 纹理单元(Texture Units) + +本文旨在帮助你直观理解 WebGL 中纹理单元是如何设置的。 +关于[属性(attributes)](webgl-attributes.html)的使用,另有一篇专门文章进行了详细讲解。 + +在阅读本文之前,你可能需要先阅读: +- [WebGL 是如何工作的](webgl-how-it-works.html) +- [WebGL 着色器与 GLSL](webgl-shaders-and-glsl.html) +- [WebGL 纹理](webgl-3d-textures.html) + +## 纹理单元(Texture Units) + +在 WebGL 中有纹理。纹理是可以传入着色器的 2D 数据数组。 +在着色器中你会这样声明一个 *uniform 采样器*: + +```glsl +uniform sampler2D someTexture; +``` + +但着色器如何知道 `someTexture` 对应的是哪一张纹理呢? + +这就是纹理单元(Texture Unit)登场的地方了。 +纹理单元是一个**全局数组**,保存着对纹理的引用。 +你可以想象,如果 WebGL 是用 JavaScript 编写的,它可能拥有如下的全局状态: + +```js +const gl = { + activeTextureUnit: 0, + textureUnits: [ + { TEXTURE_2D: null, TEXTURE_CUBE_MAP: null, TEXTURE_3D: null, TEXTURE_2D_ARRAY: null, }, + { TEXTURE_2D: null, TEXTURE_CUBE_MAP: null, TEXTURE_3D: null, TEXTURE_2D_ARRAY: null, }, + { TEXTURE_2D: null, TEXTURE_CUBE_MAP: null, TEXTURE_3D: null, TEXTURE_2D_ARRAY: null, }, + { TEXTURE_2D: null, TEXTURE_CUBE_MAP: null, TEXTURE_3D: null, TEXTURE_2D_ARRAY: null, }, + { TEXTURE_2D: null, TEXTURE_CUBE_MAP: null, TEXTURE_3D: null, TEXTURE_2D_ARRAY: null, }, + { TEXTURE_2D: null, TEXTURE_CUBE_MAP: null, TEXTURE_3D: null, TEXTURE_2D_ARRAY: null, }, + { TEXTURE_2D: null, TEXTURE_CUBE_MAP: null, TEXTURE_3D: null, TEXTURE_2D_ARRAY: null, }, + { TEXTURE_2D: null, TEXTURE_CUBE_MAP: null, TEXTURE_3D: null, TEXTURE_2D_ARRAY: null, }, + { TEXTURE_2D: null, TEXTURE_CUBE_MAP: null, TEXTURE_3D: null, TEXTURE_2D_ARRAY: null, }, + ]; +} +``` + +如上所示,`textureUnits` 是一个数组。你可以把纹理绑定到这个纹理单元数组中某个位置的 *绑定点(bind point)* 上。 +例如,将 `ourTexture` 绑定到纹理单元 5 上: + +```js +// at init time +const ourTexture = gl.createTexture(); +// insert code it init texture here. + +... + +// at render time +const indexOfTextureUnit = 5; +gl.activeTexture(gl.TEXTURE0 + indexOfTextureUnit); +gl.bindTexture(gl.TEXTURE_2D, ourTexture); +``` + +然后你需要告诉着色器这个纹理变量(uniform)使用的是哪一个纹理单元: + +```js +gl.uniform1i(someTextureUniformLocation, indexOfTextureUnit); +``` + +如果 `activeTexture` 和 `bindTexture` 函数是用 JavaScript 实现的,可能看起来像这样: + +```js +// PSEUDO CODE!!! +gl.activeTexture = function(unit) { + gl.activeTextureUnit = unit - gl.TEXTURE0; // convert to 0 based index +}; + +gl.bindTexture = function(target, texture) { + const textureUnit = gl.textureUnits[gl.activeTextureUnit]; + textureUnit[target] = texture; +}: +``` + +你甚至可以想象其它纹理相关函数是如何运作的。这些函数都接受一个 `target` 参数, +比如 `gl.texImage2D(target, ...)` 或 `gl.texParameteri(target)`,它们的实现可能像这样: + +```js +// PSEUDO CODE!!! +gl.texImage2D = function(target, level, internalFormat, width, height, border, format, type, data) { + const textureUnit = gl.textureUnits[gl.activeTextureUnit]; + const texture = textureUnit[target]; + texture.mips[level] = convertDataToInternalFormat(internalFormat, width, height, format, type, data); +} + +gl.texParameteri = function(target, pname, value) { + const textureUnit = gl.textureUnits[gl.activeTextureUnit]; + const texture = textureUnit[target]; + texture[pname] = value; +} +``` + +从以上示例可以清楚地看到,`gl.activeTexture` 会设置 WebGL 内部的一个全局变量, +表示当前使用哪个纹理单元(在纹理单元数组中的索引)。 + +从此之后,所有其它纹理函数中传入的 `target` 参数实际上就是当前激活的纹理单元中要操作的绑定点。 + +## 最大纹理单元数(Maximum Texture Units) + +WebGL 要求实现至少支持 32 个纹理单元。你可以通过如下方式查询实际支持的数量: + +```js +const maxTextureUnits = gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS); +``` + +需要注意的是,顶点着色器 和 片段着色器 对可用纹理单元数可能有不同限制。 +你可以分别用如下方式查询: + + +```js +const maxVertexShaderTextureUnits = gl.getParameter(gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS); +const maxFragmentShaderTextureUnits = gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS); +``` +它们都至少需要支持 16 个纹理单元。 + +例如: + +```js +maxTextureUnits = 32 +maxVertexShaderTextureUnits = 16 +maxFragmentShaderTextureUnits = 32 +``` + +这意味着,如果你在顶点着色器中使用了 2 个纹理单元,那么片段着色器最多只能再使用 30 个纹理单元, +因为两个着色器合起来总共不能超过 MAX_COMBINED_TEXTURE_IMAGE_UNITS 的限制(即 32)。 diff --git a/webgl/lessons/zh_cn/webgl-tips.md b/webgl/lessons/zh_cn/webgl-tips.md new file mode 100644 index 000000000..40a3a62cb --- /dev/null +++ b/webgl/lessons/zh_cn/webgl-tips.md @@ -0,0 +1,345 @@ +Title: WebGL2 小贴士 +Description: 使用 WebGL 时可能遇到的一些小问题 +TOC: # + +本文收集了一些你在使用 WebGL 时可能遇到的、看起来太小而不值得单独写一篇文章的问题。 + +--- + + + +# 画布截屏 + +在浏览器中,实际上有两种函数可以对画布进行截图。 +一种旧方法是: +[`canvas.toDataURL`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toDataURL) +另一种新的更好的方法是: +[`canvas.toBlob`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob) + +所以你可能会认为,只需添加如下代码就能轻松截图: + +```html + ++ +``` + +```js +const elem = document.querySelector('#screenshot'); +elem.addEventListener('click', () => { + canvas.toBlob((blob) => { + saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`); + }); +}); + +const saveBlob = (function() { + const a = document.createElement('a'); + document.body.appendChild(a); + a.style.display = 'none'; + return function saveData(blob, fileName) { + const url = window.URL.createObjectURL(blob); + a.href = url; + a.download = fileName; + a.click(); + }; +}()); +``` + +这是来自[动画那篇文章](webgl-animation.html)的示例,在其中加入了上面的代码,并添加了一些 CSS 来放置按钮。 + +{{{example url="../webgl-tips-screenshot-bad.html"}}} + +当我尝试时,我得到了这样的截图。 + +
+ +是的,这是一个空白图像。 + +根据你的浏览器/操作系统,它可能对你有效,但通常情况下它是无法工作的。 + +问题在于,出于性能和兼容性的考虑,浏览器默认会在你绘制完后,清除 WebGL 画布的绘图缓冲区。 + +有三种解决方案。 + +1. 在截图之前调用渲染代码 + + 我们使用的代码是一个 `drawScene` 函数。 + 最好让这段代码不改变任何状态,这样我们就可以在截图时调用它来进行渲染。 + + ```js + elem.addEventListener('click', () => { + + drawScene(); + canvas.toBlob((blob) => { + saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`); + }); + }); + ``` + +2. 在渲染循环中调用截图代码 + + 在这种情况下,我们只需设置一个标志表示我们想要截图,然后在渲染循环中实际执行截图操作。 + + ```js + let needCapture = false; + elem.addEventListener('click', () => { + needCapture = true; + }); + ``` + + 然后在我们的渲染循环中,也就是当前实现于 `drawScene` 的函数中,在所有内容绘制完成之后的某个位置。 + + ```js + function drawScene(time) { + ... + + + if (needCapture) { + + needCapture = false; + + canvas.toBlob((blob) => { + + saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`); + + }); + + } + + ... + } + ``` + +3. 在创建 WebGL 上下文时,设置 `preserveDrawingBuffer: true` + + ```js + const gl = someCanvas.getContext('webgl2', {preserveDrawingBuffer: true}); + ``` + + 这会让 WebGL 在将画布与页面其他部分合成后不清除画布,但会阻止某些*可能的*优化。 + +我会选择上面的第 1 种方法。对于这个特定示例,我首先会把更新状态的代码部分与绘制的代码部分分离开。 + +```js + var then = 0; + +- requestAnimationFrame(drawScene); ++ requestAnimationFrame(renderLoop); + ++ function renderLoop(now) { ++ // Convert to seconds ++ now *= 0.001; ++ // Subtract the previous time from the current time ++ var deltaTime = now - then; ++ // Remember the current time for the next frame. ++ then = now; ++ ++ // Every frame increase the rotation a little. ++ rotation[1] += rotationSpeed * deltaTime; ++ ++ drawScene(); ++ ++ // Call renderLoop again next frame ++ requestAnimationFrame(renderLoop); ++ } + + // Draw the scene. ++ function drawScene() { +- function drawScene(now) { +- // Convert to seconds +- now *= 0.001; +- // Subtract the previous time from the current time +- var deltaTime = now - then; +- // Remember the current time for the next frame. +- then = now; +- +- // Every frame increase the rotation a little. +- rotation[1] += rotationSpeed * deltaTime; + + webglUtils.resizeCanvasToDisplaySize(gl.canvas); + + ... + +- // Call drawScene again next frame +- requestAnimationFrame(drawScene); + } +``` + +现在我们只需在截图之前调用 `drawScene` 即可 + +```js +elem.addEventListener('click', () => { ++ drawScene(); + canvas.toBlob((blob) => { + saveBlob(blob, `screencapture-${canvas.width}x${canvas.height}.png`); + }); +}); +``` + +现在它应该可以正常工作了。 + +{{{example url="../webgl-tips-screenshot-good.html" }}} + +如果你实际检查捕获的图像,会看到背景是透明的。 +详情请参见[这篇文章](webgl-and-alpha.html)。 + +--- + + + +# 防止画布被清除 + +假设你想让用户用一个动画对象进行绘画。 +在创建 WebGL 上下文时,需要传入 `preserveDrawingBuffer: true`。 +这可以防止浏览器清除画布。 + +采用[动画那篇文章](webgl-animation.html)中的最后一个示例 + +```js +var canvas = document.querySelector("#canvas"); +-var gl = canvas.getContext("webgl2"); ++var gl = canvas.getContext("webgl2", {preserveDrawingBuffer: true}); +``` + +并修改对 `gl.clear` 的调用,使其只清除深度缓冲区。 + +``` +-// Clear the canvas. +-gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); ++// Clear the depth buffer. ++gl.clear(gl.DEPTH_BUFFER_BIT); +``` + +{{{example url="../webgl-tips-preservedrawingbuffer.html" }}} + +注意,如果你真想做一个绘图程序,这不是一个解决方案, +因为每当我们改变画布的分辨率时,浏览器仍然会清除画布。 +我们是根据显示尺寸来改变分辨率的。显示尺寸会在窗口大小改变时变化, +这可能发生在用户下载文件时,甚至在另一个标签页,浏览器添加状态栏时。 +还包括用户旋转手机,浏览器从竖屏切换到横屏时。 + +如果你真的想做绘图程序,应该[渲染到纹理](webgl-render-to-texture.html)。 + +--- + + + +# 获取键盘输入 + +如果你制作的是全页面/全屏的 WebGL 应用,那么你可以随意处理, +但通常你希望某个 canvas 只是页面的一部分, +并希望用户点击 canvas 时它能接收键盘输入。 +不过 canvas 默认是无法获取键盘输入的。为了解决这个问题, +需要将 canvas 的 [`tabindex`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/tabIndex) +设置为 0 或更大。例如: + +```html + +``` + +不过这会引发一个新问题。任何设置了 `tabindex` 的元素在获得焦点时都会被高亮显示。 +为了解决这个问题,需要将其获得焦点时的 CSS 边框(outline)设置为 none。 + +```css +canvas:focus { + outline:none; +} +``` + +为演示起见,这里有三个 canvas + +```html + + + +``` + +以及仅针对最后一个 canvas 的一些 CSS + +```css +#c3:focus { + outline: none; +} +``` + +让我们给所有 canvas 都附加相同的事件监听器 + +```js +document.querySelectorAll('canvas').forEach((canvas) => { + const ctx = canvas.getContext('2d'); + + function draw(str) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(str, canvas.width / 2, canvas.height / 2); + } + draw(canvas.id); + + canvas.addEventListener('focus', () => { + draw('has focus press a key'); + }); + + canvas.addEventListener('blur', () => { + draw('lost focus'); + }); + + canvas.addEventListener('keydown', (e) => { + draw(`keyCode: ${e.keyCode}`); + }); +}); +``` + +注意,第一个 canvas 无法接受键盘输入。 +第二个 canvas 可以,但它会被高亮显示。 +第三个 canvas 同时应用了这两个解决方案。 + +{{{example url="../webgl-tips-tabindex.html"}}} + +--- + + + +# 将背景设为WebGL动画 + +一个常见问题是如何将WebGL动画设置为网页背景。 + +以下是两种最常用的实现方式: + +* 将Canvas的CSS `position` 设置为 `fixed`,如下所示: + +```css +#canvas { + position: fixed; + left: 0; + top: 0; + z-index: -1; + ... +} +``` + +并将 `z-index` 设为 -1。 + +这种方案的一个小缺点是:你的 JavaScript 代码必须与页面其他部分兼容,如果页面比较复杂,就需要确保 WebGL 代码中的 JavaScript 不会与页面其他功能的 JavaScript 产生冲突。 + +* 使用 `iframe` + +这正是本站[首页](/)采用的解决方案。 + +在您的网页中,只需插入一个iframe即可实现,例如: + +```html + +
+ Your content goes here. +
+``` + +接下来将这个iframe设置为全屏背景样式,本质上和我们之前设置canvas的代码相同——只是需要额外将 `border` 设为 `none`,因为iframe默认带有边框。具体实现如下: + +```css +#background { + position: fixed; + width: 100vw; + height: 100vh; + left: 0; + top: 0; + z-index: -1; + border: none; + pointer-events: none; +} +``` + +{{{example url="../webgl-tips-html-background.html"}}}