diff --git a/manual/list.json b/manual/list.json index bbba61cf0c68d7..a6465794bbc7d7 100644 --- a/manual/list.json +++ b/manual/list.json @@ -342,8 +342,8 @@ "后期处理": "zh/post-processing", "Applying a LUT File for effects": "zh/post-processing-3dlut", "Using Shadertoy shaders": "zh/shadertoy", - "对齐HTML元素和3D对象": "zh/align-html-elements-to-3d", - "Using Indexed Textures for Picking and Color": "zh/indexed-textures", + "对齐HTML元素到3D对象": "zh/align-html-elements-to-3d", + "使用纹理索引来拾取和着色": "zh/indexed-textures", "使用Canvas生成动态纹理": "zh/canvas-textures", "广告牌(Billboards)": "zh/billboards", "释放资源": "zh/cleanup", @@ -359,4 +359,4 @@ "材质特性表": "zh/material-table" } } -} +} \ No newline at end of file diff --git a/manual/zh/align-html-elements-to-3d.html b/manual/zh/align-html-elements-to-3d.html index 35c5c4733d4862..9fe9f53befce34 100644 --- a/manual/zh/align-html-elements-to-3d.html +++ b/manual/zh/align-html-elements-to-3d.html @@ -3,7 +3,7 @@
-抱歉,还没有中文翻译哦。 欢迎加入翻译! 😄
- + + + + +这篇文章是 对齐HTML元素到3D对象 的延续。 + 如果你还没有读过上篇文章,你应该先从那里开始,然后再回来继续阅读。
+有时候使用Three.js需要提出一些创造性的解决思路。我不确定这是一个很好的解决方案,但我想我会分享它,你可以看看是否可以为你的需求提供了一些解决思路或方案。
+在 上一篇文章中,我们在3D地球周围显示了国家名称,那么我们如何做到,让用户选中一个国家并高亮他的选择?
+第一个想法是为每个国家生成几何图形,我们可以 使用射线拾取 ,就像之前介绍的那样。 + 我们将为每个国家构建3D几何对象。如果用户点击代表那个国家的网格对象,我们就会知道对应的国家被点击了。
+所以,为了验证这个解决方案,我尝试生成所有国家的3D网格对象,使用了在上一篇文章中和我生成轮廓一样的数据。 + 结果生成了15.5m的二进制GLTF(.glb)文件,让用户下载15.5m的数据对于我来说实在太多了。 +
+有很多方法可以压缩数据。第一种可能是应用一些算法来降低轮廓的分辨率,但是我没有花时间来研究它。可能出现美国边界变大而加拿大边界变小的情况。
+另一种解决方案是仅使用数据压缩,比如gzip将其降至11m,这减少了30%,但是还不够。
+我们可以将所有数据存储为16位而不是32位浮点值。或者我们也可以使用像draco 压缩 + 这种东西也许就够了。不过我没有去试,我推荐你去试下回来告诉我是怎么回事,因为我很想知道😅
+就我而言,我考虑使用 GPU拾取方案, + 这在上一篇 关于拾取的文章 的最后有提到。这种方案中,我们使用一种独特的颜色代表不同网格对象的ID,然后我们绘制了所有网格,看看哪个颜色被点击了。
+基于这种灵感,我们可以预先生成一张国家的地图,每个国家的颜色是它在国家数组中的索引号。我们可以使用类似GPU拾取技术,我们使用索引纹理绘制一个离屏全局画布,查看颜色会告诉我们用户点击了那个国家ID。
+因此,我 写了一些代码 + 生成这样的一个纹理,在这里:
+ + +注意:生成这份纹理的数据来源于 这个网站 + ,使用的协议是 CC-BY-SA。
+它只有217k,比国家网格对象的15m要好得多,事实上我们可以使用更低的分辨率,但现在217k似乎已经足够了。
+所以让我们试着用它来选择国家。
+从 GPU拾取案例中 获取代码,我们需要一个场景来做拾取。
+const pickingScene = new THREE.Scene(); +pickingScene.background = new THREE.Color(0); ++
我们需要将带有索引纹理的地球添加到拾取场景中。
+{ + const loader = new THREE.TextureLoader(); + const geometry = new THREE.SphereGeometry(1, 64, 32); + ++ const indexTexture = loader.load('resources/data/world/country-index-texture.png', render); ++ indexTexture.minFilter = THREE.NearestFilter; ++ indexTexture.magFilter = THREE.NearestFilter; ++ ++ const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture}); ++ pickingScene.add(new THREE.Mesh(geometry, pickingMaterial)); + + const texture = loader.load('resources/data/world/country-outlines-4k.png', render); + const material = new THREE.MeshBasicMaterial({map: texture}); + scene.add(new THREE.Mesh(geometry, material)); +} ++
然后我们把 GPUPickingHelper
这个类拷贝下,在使用前我们需要做一些小改动
class GPUPickHelper { + constructor() { + // 创造一个 1x1 的渲染对象 + this.pickingTexture = new THREE.WebGLRenderTarget(1, 1); + this.pixelBuffer = new Uint8Array(4); +- this.pickedObject = null; +- this.pickedObjectSavedColor = 0; + } + pick(cssPosition, scene, camera) { + const {pickingTexture, pixelBuffer} = this; + + // 将视图偏移设置为仅表示鼠标下单个元素 + const pixelRatio = renderer.getPixelRatio(); + camera.setViewOffset( + renderer.getContext().drawingBufferWidth, // full width + renderer.getContext().drawingBufferHeight, // full top + cssPosition.x * pixelRatio | 0, // rect x + cssPosition.y * pixelRatio | 0, // rect y + 1, // rect width + 1, // rect height + ); + // 渲染场景 + renderer.setRenderTarget(pickingTexture); + renderer.render(scene, camera); + renderer.setRenderTarget(null); + // 清除视图偏移,使渲染恢复正常 + camera.clearViewOffset(); + // 读取像素 + renderer.readRenderTargetPixels( + pickingTexture, + 0, // x + 0, // y + 1, // width + 1, // height + pixelBuffer); + ++ const id = ++ (pixelBuffer[0] << 16) | ++ (pixelBuffer[1] << 8) | ++ (pixelBuffer[2] << 0); ++ ++ return id; +- const id = +- (pixelBuffer[0] << 16) | +- (pixelBuffer[1] << 8) | +- (pixelBuffer[2] ); +- const intersectedObject = idToObject[id]; +- if (intersectedObject) { +- // 获取第一个对象,它是离我们最近的 +- this.pickedObject = intersectedObject; +- // 保存它的颜色 +- this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex(); +- // 将其自发光颜色设置为闪烁的红色/黄色 +- this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000); +- } + } +} ++
现在我们可以用它来选择国家了。
+const pickHelper = new GPUPickHelper(); + +function getCanvasRelativePosition(event) { + const rect = canvas.getBoundingClientRect(); + return { + x: (event.clientX - rect.left) * canvas.width / rect.width, + y: (event.clientY - rect.top ) * canvas.height / rect.height, + }; +} + +function pickCountry(event) { + // 如果我们还没有加载好数据,退出 + if (!countryInfos) { + return; + } + + const position = getCanvasRelativePosition(event); + const id = pickHelper.pick(position, pickingScene, camera); + if (id > 0) { + // 我们点击了一个国家,修改它的selected属性 + const countryInfo = countryInfos[id - 1]; + const selected = !countryInfo.selected; + // 如果我们选中这个国家,并且没有按住控制键,取消所有选中的国家 + if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) { + unselectAllCountries(); + } + numCountriesSelected += selected ? 1 : -1; + countryInfo.selected = selected; + } else if (numCountriesSelected) { + // 海洋或者天空被选中了 + unselectAllCountries(); + } + requestRenderIfNotRequested(); +} + +function unselectAllCountries() { + numCountriesSelected = 0; + countryInfos.forEach((countryInfo) => { + countryInfo.selected = false; + }); +} + +canvas.addEventListener('pointerup', pickCountry); ++
上面的代码,设置/重置了国家数组元素的 selected
属性。如果 shift
或 ctrl
或 cmd
+ 被按下了,你就可以选择多个国家。
剩下的就是显示选择的国家,现在让我们更新标签
+function updateLabels() { + // 如果我们还没有加载好数据,退出 + if (!countryInfos) { + return; + } + + const large = settings.minArea * settings.minArea; + // 获取表示相机正对方向的矩阵 + normalMatrix.getNormalMatrix(camera.matrixWorldInverse); + // 获取相机位置 + camera.getWorldPosition(cameraPosition); + for (const countryInfo of countryInfos) { +- const {position, elem, area} = countryInfo; +- // 足够大了? +- if (area < large) { ++ const {position, elem, area, selected} = countryInfo; ++ const largeEnough = area >= large; ++ const show = selected || (numCountriesSelected === 0 && largeEnough); ++ if (!show) { + elem.style.display = 'none'; + continue; + } + + ... ++
通过上面的代码,我们就有能力拾取对应的国家了。
+ +代码仍然会根据地区显示对应的国家。不过如果你点击一个,只会显示对应的标签。
+所以这似乎是选择国家的有效解决方案,但是如何突出显示选定的国家?
+我们可以从 调色板图形算法 中获取灵感。
+调色板算法 + 或者 索引颜色 + 是被旧的系统,比如Atari 800、Amiga、NES、Super Nintendolder及IBM + PCs所使用的。并非以RGBA的形式给每个颜色存储8位、每个像素至少32字节的位图,他们存储位图是8位或者更少。每个像素都是一个调色板的索引值, + 所以举例一个像素值为3,表示“显示3号颜色值”,而定义3号颜色值的地方就叫“调色板”。
+在JavaScript中就像这样:
+const face7x7PixelImageData = [ + 0, 1, 1, 1, 1, 1, 0, + 1, 0, 0, 0, 0, 0, 1, + 1, 0, 2, 0, 2, 0, 1, + 1, 0, 0, 0, 0, 0, 1, + 1, 0, 3, 3, 3, 0, 1, + 1, 0, 0, 0, 0, 0, 1, + 0, 1, 1, 1, 1, 1, 1, +]; + +const palette = [ + [255, 255, 255], // 白 + [ 0, 0, 0], // 黑 + [ 0, 255, 255], // 青 + [255, 0, 0], // 红 +]; ++
图像数据中每个像素都是调色板的索引,如果你分析上面调色板的数据你会得到这个图像:
+ + ++ 在我们的例子中,上面已经有一个用不同id代表不同国家的纹理了,所以我们可以通过调色板使用相同的纹理赋予每个国家各自的颜色。通过更改调色板颜色我们可以为单独的国家着色。比如通过设置整个调色板纹理为黑色,给某个国家使用不同的颜色,就可以凸显那个国家了。 +
+要做调色板索引的话,需要一些自定义着色器代码,让我们修改Three.js中默认的着色器,这样我们也可以根据需要使用照明或其他特性。
+就像我们在 大量移动物体的优化 这篇文章中提到的,通过
+ onBeforeCompile
属性,我们可以通过向材质添加函数来修改默认的着色器。
+
默认的片元着色器在编译之前看起来就像这样:
+#include <common> +#include <color_pars_fragment> +#include <uv_pars_fragment> +#include <uv2_pars_fragment> +#include <map_pars_fragment> +#include <alphamap_pars_fragment> +#include <aomap_pars_fragment> +#include <lightmap_pars_fragment> +#include <envmap_pars_fragment> +#include <fog_pars_fragment> +#include <specularmap_pars_fragment> +#include <logdepthbuf_pars_fragment> +#include <clipping_planes_pars_fragment> +void main() { + #include <clipping_planes_fragment> + vec4 diffuseColor = vec4( diffuse, opacity ); + #include <logdepthbuf_fragment> + #include <map_fragment> + #include <color_fragment> + #include <alphamap_fragment> + #include <alphatest_fragment> + #include <specularmap_fragment> + ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) ); + #ifdef USE_LIGHTMAP + reflectedLight.indirectDiffuse += texture2D( lightMap, vUv2 ).xyz * lightMapIntensity; + #else + reflectedLight.indirectDiffuse += vec3( 1.0 ); + #endif + #include <aomap_fragment> + reflectedLight.indirectDiffuse *= diffuseColor.rgb; + vec3 outgoingLight = reflectedLight.indirectDiffuse; + #include <envmap_fragment> + gl_FragColor = vec4( outgoingLight, diffuseColor.a ); + #include <premultiplied_alpha_fragment> + #include <tonemapping_fragment> + #include <encodings_fragment> + #include <fog_fragment> +} ++
查看所有的片段
+ 我们发现THREE.js使用了一个名为 diffuseColor
的变量去管理基本材质颜色。它在这里设置: <color_fragment>
片段
+ 所以我们应该能够在这部分之后进行修改。
diffuseColor
+ 在这个时刻应该已经是从我们轮廓纹理中获取的颜色了,所以我们应该可以从调色盘中获取颜色,然后把他们和最终颜色混合。
就像我们 之前做的那样 ,
+ 在Material.onBeforeCompile
我们使用一个用来搜索和替换着色器代码的数组。
+
{ + const loader = new THREE.TextureLoader(); + const geometry = new THREE.SphereGeometry(1, 64, 32); + + const indexTexture = loader.load('resources/data/world/country-index-texture.png', render); + indexTexture.minFilter = THREE.NearestFilter; + indexTexture.magFilter = THREE.NearestFilter; + + const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture}); + pickingScene.add(new THREE.Mesh(geometry, pickingMaterial)); + ++ const fragmentShaderReplacements = [ ++ { ++ from: '#include <common>', ++ to: ` ++ #include <common> ++ uniform sampler2D indexTexture; ++ uniform sampler2D paletteTexture; ++ uniform float paletteTextureWidth; ++ `, ++ }, ++ { ++ from: '#include <color_fragment>', ++ to: ` ++ #include <color_fragment> ++ { ++ vec4 indexColor = texture2D(indexTexture, vUv); ++ float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0; ++ vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5); ++ vec4 paletteColor = texture2D(paletteTexture, paletteUV); ++ // diffuseColor.rgb += paletteColor.rgb; // 白轮廓 ++ diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb; // 黑轮廓 ++ } ++ `, ++ }, ++ ]; + const texture = loader.load('resources/data/world/country-outlines-4k.png', render); + const material = new THREE.MeshBasicMaterial({map: texture}); ++ material.onBeforeCompile = function(shader) { ++ fragmentShaderReplacements.forEach((rep) => { ++ shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to); ++ }); ++ }; + scene.add(new THREE.Mesh(geometry, material)); +} ++
在上面可以看到我们添加了3个uniforms变量,indexTexture
, paletteTexture
,
+ and paletteTextureWidth
。我们从 indexTexture
+ 获取颜色,并且把它转化成索引下标。 vUv
+ 是由Three.js提供的纹理坐标。然后我们使用索引下标从调色板中获取颜色。然后我们使用当前的 diffuseColor
和最终的结果作混合。
+ diffuseColor
在此时是我们黑色纹理,而调色盘是白色纹理。所以如果我们相加两个颜色,得出的是白色轮廓。如果我们二者相减,得出的是黑色轮廓。
+
在我们渲染前,我们还需要设置调色板纹理,以及这3个uniforms变量。
+对于调色板纹理,它只需要足够宽即可。每个国家保留一种颜色 + 一种海洋颜色。这里有240个国家或地区,我们可以等到国家列表加载完成后以获取确切的数字来查找。不过选择一些更大的数字没有危害,所以让我们选择512。
+这里是创建调色板的代码
+const maxNumCountries = 512; +const paletteTextureWidth = maxNumCountries; +const paletteTextureHeight = 1; +const palette = new Uint8Array(paletteTextureWidth * 4); +const paletteTexture = new THREE.DataTexture( + palette, paletteTextureWidth, paletteTextureHeight); +paletteTexture.minFilter = THREE.NearestFilter; +paletteTexture.magFilter = THREE.NearestFilter; ++
一个DataTexture
提供原始的纹理数据。在这种情况下,我们给他512个RGBA颜色,每个颜色4字节,每个包含0-255的红、绿、蓝分量。
让我们用随机颜色填充它,只是为了看看它是否有效
+for (let i = 1; i < palette.length; ++i) { + palette[i] = Math.random() * 256; +} +// 设置海洋颜色 (索引 #0) +palette.set([100, 200, 255, 255], 0); +paletteTexture.needsUpdate = true; ++
任何时候我们想要Three.js通过 palette
数组的内容来更新调色板上的纹理,我们需要去设置paletteTexture.needsUpdate
+ 为 true
。
然后我们还需要设置材质上的uniforms变量
+const geometry = new THREE.SphereGeometry(1, 64, 32); +const material = new THREE.MeshBasicMaterial({map: texture}); +material.onBeforeCompile = function(shader) { + fragmentShaderReplacements.forEach((rep) => { + shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to); + }); ++ shader.uniforms.paletteTexture = {value: paletteTexture}; ++ shader.uniforms.indexTexture = {value: indexTexture}; ++ shader.uniforms.paletteTextureWidth = {value: paletteTextureWidth}; +}; +scene.add(new THREE.Mesh(geometry, material)); ++
这样我们就得到了随机着色的国家
+ + + + +现在我们可以看到索引和调色板纹理生效了,让我们控制调色板进行高亮显示
+首先让我们创建一个函数,我们传入一个THREE.js颜色,并格式化为可以放入调色板纹理的值。
+const tempColor = new THREE.Color(); +function get255BasedColor(color) { + tempColor.set(color); + const base = tempColor.toArray().map(v => v * 255); + base.push(255); // alpha + return base; +} ++
像这样来调用 color = get255BasedColor('red')
会返回一个像 [255, 0, 0, 255]
这样的数组。
接下来让我们用它来生成一些颜色并填充调色板。
+const selectedColor = get255BasedColor('red'); +const unselectedColor = get255BasedColor('#444'); +const oceanColor = get255BasedColor('rgb(100,200,255)'); +resetPalette(); + +function setPaletteColor(index, color) { + palette.set(color, index * 4); +} + +function resetPalette() { + // 让所有的颜色都是未选择状态的颜色 + for (let i = 1; i < maxNumCountries; ++i) { + setPaletteColor(i, unselectedColor); + } + + // 设置海洋颜色 (索引 #0) + setPaletteColor(0, oceanColor); + paletteTexture.needsUpdate = true; +} ++
现在让我们使用这些函数来更新调色板,当一个国家被选中时:
+function getCanvasRelativePosition(event) { + const rect = canvas.getBoundingClientRect(); + return { + x: (event.clientX - rect.left) * canvas.width / rect.width, + y: (event.clientY - rect.top ) * canvas.height / rect.height, + }; +} + +function pickCountry(event) { + // 如果我们还没有加载好数据,退出 + if (!countryInfos) { + return; + } + + const position = getCanvasRelativePosition(event); + const id = pickHelper.pick(position, pickingScene, camera); + if (id > 0) { + const countryInfo = countryInfos[id - 1]; + const selected = !countryInfo.selected; + if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) { + unselectAllCountries(); + } + numCountriesSelected += selected ? 1 : -1; + countryInfo.selected = selected; ++ setPaletteColor(id, selected ? selectedColor : unselectedColor); ++ paletteTexture.needsUpdate = true; + } else if (numCountriesSelected) { + unselectAllCountries(); + } + requestRenderIfNotRequested(); +} + +function unselectAllCountries() { + numCountriesSelected = 0; + countryInfos.forEach((countryInfo) => { + countryInfo.selected = false; + }); ++ resetPalette(); +} ++
我们应该能够突出显示1个或多个国家。
+ +这看起来有效!
+一件小事是我们不能不改变选中状态就渲染地球。如果我们选择一个国家然后想要旋转地球,选中状态将改变。
+让我们尝试解决这个问题。我认为,我们可以检查两件事情。点击和松开经过了多少时间;用户是否移动了鼠标。如果时间很短,或者他们没有移动鼠标,那么这个行为可能是点击。否则他们可能正在尝试拖动地球。
++const maxClickTimeMs = 200; ++const maxMoveDeltaSq = 5 * 5; ++const startPosition = {}; ++let startTimeMs; ++ ++function recordStartTimeAndPosition(event) { ++ startTimeMs = performance.now(); ++ const pos = getCanvasRelativePosition(event); ++ startPosition.x = pos.x; ++ startPosition.y = pos.y; ++} + +function getCanvasRelativePosition(event) { + const rect = canvas.getBoundingClientRect(); + return { + x: (event.clientX - rect.left) * canvas.width / rect.width, + y: (event.clientY - rect.top ) * canvas.height / rect.height, + }; +} + +function pickCountry(event) { + // exit if we have not loaded the data yet + if (!countryInfos) { + return; + } + ++ // 如果用户触发后已经过了一段时间了 ++ // 就认为这是一个拖动行为 ++ const clickTimeMs = performance.now() - startTimeMs; ++ if (clickTimeMs > maxClickTimeMs) { ++ return; ++ } ++ ++ // 如果鼠标移动了,就认为这是一个拖动行为 ++ const position = getCanvasRelativePosition(event); ++ const moveDeltaSq = (startPosition.x - position.x) ** 2 + ++ (startPosition.y - position.y) ** 2; ++ if (moveDeltaSq > maxMoveDeltaSq) { ++ return; ++ } + +- const position = {x: event.clientX, y: event.clientY}; + const id = pickHelper.pick(position, pickingScene, camera); + if (id > 0) { + const countryInfo = countryInfos[id - 1]; + const selected = !countryInfo.selected; + if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) { + unselectAllCountries(); + } + numCountriesSelected += selected ? 1 : -1; + countryInfo.selected = selected; + setPaletteColor(id, selected ? selectedColor : unselectedColor); + paletteTexture.needsUpdate = true; + } else if (numCountriesSelected) { + unselectAllCountries(); + } + requestRenderIfNotRequested(); +} + +function unselectAllCountries() { + numCountriesSelected = 0; + countryInfos.forEach((countryInfo) => { + countryInfo.selected = false; + }); + resetPalette(); +} + ++canvas.addEventListener('pointerdown', recordStartTimeAndPosition); +canvas.addEventListener('pointerup', pickCountry); ++
添加了这些操作,这看起来 对我有效。
+ +我不是用户交互的专家,所以我很想知道是否有更好的解决方案。
+我希望这能让你了解图形索引的用处,以及如何修改Three.js的着色器以添加简单的功能。对于如何使用GLSL,编写着色器语言对于本文来说太多了,这个链接有一些少量的信息,参考 + 关于后处理的这篇文章。 +
+