1
0

voxel-geometry.html 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075
  1. <!DOCTYPE html>
  2. <html lang="zh">
  3. <head>
  4. <meta charset="utf-8">
  5. <title>体素(类似《我的世界》)几何体</title>
  6. <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
  7. <meta name="twitter:card" content="summary_large_image">
  8. <meta name="twitter:site" content="@threejs">
  9. <meta name="twitter:title" content="Three.js – 体素(类似《我的世界》)几何体">
  10. <meta property="og:image" content="https://threejs.org/files/share.png">
  11. <link rel="shortcut icon" href="../../files/favicon_white.ico" media="(prefers-color-scheme: dark)">
  12. <link rel="shortcut icon" href="../../files/favicon.ico" media="(prefers-color-scheme: light)">
  13. <link rel="stylesheet" href="../resources/lesson.css">
  14. <link rel="stylesheet" href="../resources/lang.css">
  15. <script type="importmap">
  16. {
  17. "imports": {
  18. "three": "../../build/three.module.js"
  19. }
  20. }
  21. </script>
  22. </head>
  23. <body>
  24. <div class="container">
  25. <div class="lesson-title">
  26. <h1>体素(类似《我的世界》)几何体</h1>
  27. </div>
  28. <div class="lesson">
  29. <div class="lesson-main">
  30. <p>我在多个地方都看到过这个话题:“如何实现像《我的世界》那样的体素显示”。</p>
  31. <p>大多数人初次尝试时,会为每个体素位置创建一个立方体几何体,然后生成一个网格(mesh)。出于好奇,我也试了一下。我创建了一个包含 16777216 个元素的 <code class="notranslate" translate="no">Uint8Array</code> 数组,用来表示一个 256x256x256 的体素立方体。</p>
  32. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const cellSize = 256;
  33. const cell = new Uint8Array(cellSize * cellSize * cellSize);
  34. </pre>
  35. <p>然后我用正弦波生成了一层类似小山丘的地形,如下所示:</p>
  36. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">for (let y = 0; y &lt; cellSize; ++y) {
  37. for (let z = 0; z &lt; cellSize; ++z) {
  38. for (let x = 0; x &lt; cellSize; ++x) {
  39. const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
  40. if (height &gt; y &amp;&amp; height &lt; y + 1) {
  41. const offset = y * cellSize * cellSize +
  42. z * cellSize +
  43. x;
  44. cell[offset] = 1;
  45. }
  46. }
  47. }
  48. }
  49. </pre>
  50. <p>接着我遍历所有体素,只要值不为 0,就创建一个立方体网格:</p>
  51. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const geometry = new THREE.BoxGeometry(1, 1, 1);
  52. const material = new THREE.MeshPhongMaterial({color: 'green'});
  53. for (let y = 0; y &lt; cellSize; ++y) {
  54. for (let z = 0; z &lt; cellSize; ++z) {
  55. for (let x = 0; x &lt; cellSize; ++x) {
  56. const offset = y * cellSize * cellSize +
  57. z * cellSize +
  58. x;
  59. const block = cell[offset];
  60. const mesh = new THREE.Mesh(geometry, material);
  61. mesh.position.set(x, y, z);
  62. scene.add(mesh);
  63. }
  64. }
  65. }
  66. </pre>
  67. <p>其余代码基于 <a href="rendering-on-demand.html">“按需渲染”</a>一文中的示例。</p>
  68. <p></p>
  69. <div translate="no" class="threejs_example_container notranslate">
  70. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/voxel-geometry-separate-cubes.html"></iframe></div>
  71. <a class="threejs_center" href="/manual/examples/voxel-geometry-separate-cubes.html" target="_blank">点击此处,在新窗口中打开示例</a>
  72. </div>
  73. <p></p>
  74. <p>页面加载需要较长时间,如果你尝试移动摄像机,很可能非常卡顿。就像 <a href="optimize-lots-of-objects.html">“如何优化大量对象”</a>一文中提到的,问题在于对象数量太多——仅 256x256 就有 65536 个方块!</p>
  75. <p>使用 <a href="rendering-on-demand.html">“合并几何体”</a> 技术可以解决本例的问题。但如果不仅仅是生成单层地形,而是将地面以下的所有空间都用体素填充呢?换句话说,将填充体素的循环修改如下:</p>
  76. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">for (let y = 0; y &lt; cellSize; ++y) {
  77. for (let z = 0; z &lt; cellSize; ++z) {
  78. for (let x = 0; x &lt; cellSize; ++x) {
  79. const height = (Math.sin(x / cellSize * Math.PI * 4) + Math.sin(z / cellSize * Math.PI * 6)) * 20 + cellSize / 2;
  80. - if (height &gt; y &amp;&amp; height &lt; y + 1) {
  81. + if (height &lt; y + 1) {
  82. const offset = y * cellSize * cellSize +
  83. z * cellSize +
  84. x;
  85. cell[offset] = 1;
  86. }
  87. }
  88. }
  89. }
  90. </pre>
  91. <p>我尝试运行了一次,只是为了看看结果。程序运行了大约一分钟,然后因 <em>内存不足</em> 而崩溃了 😅</p>
  92. <p>这里存在多个问题,但最严重的是:我们生成了大量立方体内部的面片(faces),而这些面实际上永远不可见。</p>
  93. <p>换句话说,假设我们有一个 3x2x2 的体素方块。如果我们只是简单合并立方体,会得到如下结构:</p>
  94. <div class="spread">
  95. <div data-diagram="mergedCubes" style="height: 300px;"></div>
  96. </div>
  97. <p>但实际上我们想要的是这个:</p>
  98. <div class="spread">
  99. <div data-diagram="culledCubes" style="height: 300px;"></div>
  100. </div>
  101. <p>在上方的盒子中,体素之间存在面片。这些面是完全浪费的,因为它们永远不可见。而且不只是每个体素之间一个面,实际上是两个面——每个体素朝向其邻居的那个面都是多余的。对于大量体素来说,这些额外的面会严重拖累性能。</p>
  102. <p>显然,我们不能简单地合并几何体。我们必须自己构建几何体,并考虑:如果一个体素有相邻的邻居,那么它就不需要朝向该邻居的那个面。</p>
  103. <p>下一个问题是:256x256x256 太大了。16 兆字节的内存占用已经很高,而且大部分空间其实是空的,造成了大量内存浪费。同时体素总数高达 1600 万个!一次性处理这么多数据是不现实的。</p>
  104. <p>解决方案是将区域划分为更小的区域。任何完全为空的区域都不需要存储。我们使用 32x32x32 的小区域(每个约 32KB),仅在其中有数据时才创建。我们将这种 32x32x32 的区域称为一个“单元”(cell)。</p>
  105. <p>让我们逐步实现。首先创建一个类来管理体素数据:</p>
  106. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class VoxelWorld {
  107. constructor(cellSize) {
  108. this.cellSize = cellSize;
  109. }
  110. }
  111. </pre>
  112. <p>接下来编写一个为“单元”生成几何体的函数。假设你传入一个单元的坐标。例如,如果你想获取覆盖体素 (0-31x, 0-31y, 0-31z) 的单元的几何体,就传入 0,0,0;如果想获取覆盖 (32-63x, 0-31y, 0-31z) 的单元,则传入 1,0,0。</p>
  113. <p>我们需要能够检查相邻体素,因此假设我们的类有一个 <code class="notranslate" translate="no">getVoxel</code> 方法,它接收体素坐标并返回该位置的体素值。例如,传入 35,0,0 且 cellSize 为 32 时,它会查找单元 (1,0,0),并在该单元中访问体素 (3,0,0)。通过这个方法,即使相邻体素位于其他单元中,我们也能正确访问。</p>
  114. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class VoxelWorld {
  115. constructor(cellSize) {
  116. this.cellSize = cellSize;
  117. }
  118. + generateGeometryDataForCell(cellX, cellY, cellZ) {
  119. + const {cellSize} = this;
  120. + const startX = cellX * cellSize;
  121. + const startY = cellY * cellSize;
  122. + const startZ = cellZ * cellSize;
  123. +
  124. + for (let y = 0; y &lt; cellSize; ++y) {
  125. + const voxelY = startY + y;
  126. + for (let z = 0; z &lt; cellSize; ++z) {
  127. + const voxelZ = startZ + z;
  128. + for (let x = 0; x &lt; cellSize; ++x) {
  129. + const voxelX = startX + x;
  130. + const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
  131. + if (voxel) {
  132. + for (const {dir} of VoxelWorld.faces) {
  133. + const neighbor = this.getVoxel(
  134. + voxelX + dir[0],
  135. + voxelY + dir[1],
  136. + voxelZ + dir[2]);
  137. + if (!neighbor) {
  138. + // 该体素在此方向上没有邻居,因此需要生成一个面
  139. + }
  140. + }
  141. + }
  142. + }
  143. + }
  144. + }
  145. + }
  146. }
  147. +VoxelWorld.faces = [
  148. + { // 左侧
  149. + dir: [ -1, 0, 0 ],
  150. + },
  151. + { // 右侧
  152. + dir: [ 1, 0, 0 ],
  153. + },
  154. + { // 底部
  155. + dir: [ 0, -1, 0 ],
  156. + },
  157. + { // 顶部
  158. + dir: [ 0, 1, 0 ],
  159. + },
  160. + { // 背面
  161. + dir: [ 0, 0, -1 ],
  162. + },
  163. + { // 前面
  164. + dir: [ 0, 0, 1 ],
  165. + },
  166. +];
  167. </pre>
  168. <p>通过上述代码,我们已经知道何时需要生成一个面。现在来实际生成这些面。</p>
  169. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class VoxelWorld {
  170. constructor(cellSize) {
  171. this.cellSize = cellSize;
  172. }
  173. generateGeometryDataForCell(cellX, cellY, cellZ) {
  174. const {cellSize} = this;
  175. + const positions = [];
  176. + const normals = [];
  177. + const indices = [];
  178. const startX = cellX * cellSize;
  179. const startY = cellY * cellSize;
  180. const startZ = cellZ * cellSize;
  181. for (let y = 0; y &lt; cellSize; ++y) {
  182. const voxelY = startY + y;
  183. for (let z = 0; z &lt; cellSize; ++z) {
  184. const voxelZ = startZ + z;
  185. for (let x = 0; x &lt; cellSize; ++x) {
  186. const voxelX = startX + x;
  187. const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
  188. if (voxel) {
  189. - for (const {dir} of VoxelWorld.faces) {
  190. + for (const {dir, corners} of VoxelWorld.faces) {
  191. const neighbor = this.getVoxel(
  192. voxelX + dir[0],
  193. voxelY + dir[1],
  194. voxelZ + dir[2]);
  195. if (!neighbor) {
  196. // 该体素在此方向上没有邻居,因此需要生成一个面
  197. + const ndx = positions.length / 3;
  198. + for (const pos of corners) {
  199. + positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
  200. + normals.push(...dir);
  201. + }
  202. + indices.push(
  203. + ndx, ndx + 1, ndx + 2,
  204. + ndx + 2, ndx + 1, ndx + 3
  205. + );
  206. }
  207. }
  208. }
  209. }
  210. }
  211. }
  212. + return {
  213. + positions,
  214. + normals,
  215. + indices
  216. + };
  217. }
  218. }
  219. VoxelWorld.faces = [
  220. { // 左侧
  221. dir: [ -1, 0, 0 ],
  222. + corners: [
  223. + [ 0, 1, 0 ],
  224. + [ 0, 0, 0 ],
  225. + [ 0, 1, 1 ],
  226. + [ 0, 0, 1 ]
  227. + ]
  228. },
  229. { // 右侧
  230. dir: [ 1, 0, 0 ],
  231. + corners: [
  232. + [ 1, 1, 1 ],
  233. + [ 1, 0, 1 ],
  234. + [ 1, 1, 0 ],
  235. + [ 1, 0, 0 ]
  236. + ]
  237. },
  238. { // 底部
  239. dir: [ 0, -1, 0 ],
  240. + corners: [
  241. + [ 1, 0, 1 ],
  242. + [ 0, 0, 1 ],
  243. + [ 1, 0, 0 ],
  244. + [ 0, 0, 0 ]
  245. + ]
  246. },
  247. { // 顶部
  248. dir: [ 0, 1, 0 ],
  249. + corners: [
  250. + [ 0, 1, 1 ],
  251. + [ 1, 1, 1 ],
  252. + [ 0, 1, 0 ],
  253. + [ 1, 1, 0 ]
  254. + ]
  255. },
  256. { // 背面
  257. dir: [ 0, 0, -1 ],
  258. + corners: [
  259. + [ 1, 0, 0 ],
  260. + [ 0, 0, 0 ],
  261. + [ 1, 1, 0 ],
  262. + [ 0, 1, 0 ]
  263. + ]
  264. },
  265. { // 前面
  266. dir: [ 0, 0, 1 ],
  267. + corners: [
  268. + [ 0, 0, 1 ],
  269. + [ 1, 0, 1 ],
  270. + [ 0, 1, 1 ],
  271. + [ 1, 1, 1 ]
  272. + ]
  273. }
  274. ];
  275. </pre>
  276. <p>上面的代码已经可以为我们生成基本的几何数据,我们只需要提供 <code class="notranslate" translate="no">getVoxel</code> 函数即可。我们先从一个硬编码的单元开始实现。</p>
  277. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class VoxelWorld {
  278. constructor(cellSize) {
  279. this.cellSize = cellSize;
  280. + this.cell = new Uint8Array(cellSize * cellSize * cellSize);
  281. }
  282. + getCellForVoxel(x, y, z) {
  283. + const {cellSize} = this;
  284. + const cellX = Math.floor(x / cellSize);
  285. + const cellY = Math.floor(y / cellSize);
  286. + const cellZ = Math.floor(z / cellSize);
  287. + if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
  288. + return null;
  289. + }
  290. + return this.cell;
  291. + }
  292. + getVoxel(x, y, z) {
  293. + const cell = this.getCellForVoxel(x, y, z);
  294. + if (!cell) {
  295. + return 0;
  296. + }
  297. + const {cellSize} = this;
  298. + const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
  299. + const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
  300. + const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
  301. + const voxelOffset = voxelY * cellSize * cellSize +
  302. + voxelZ * cellSize +
  303. + voxelX;
  304. + return cell[voxelOffset];
  305. + }
  306. generateGeometryDataForCell(cellX, cellY, cellZ) {
  307. ...
  308. }
  309. </pre>
  310. <p>这段代码看起来可以正常工作了。我们再添加一个 <code class="notranslate" translate="no">setVoxel</code> 函数,以便可以设置一些体素数据。</p>
  311. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class VoxelWorld {
  312. constructor(cellSize) {
  313. this.cellSize = cellSize;
  314. this.cell = new Uint8Array(cellSize * cellSize * cellSize);
  315. }
  316. getCellForVoxel(x, y, z) {
  317. const {cellSize} = this;
  318. const cellX = Math.floor(x / cellSize);
  319. const cellY = Math.floor(y / cellSize);
  320. const cellZ = Math.floor(z / cellSize); if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
  321. return null;
  322. }
  323. return this.cell;
  324. }
  325. + setVoxel(x, y, z, v) {
  326. + let cell = this.getCellForVoxel(x, y, z);
  327. + if (!cell) {
  328. + return; // TODO: 是否应添加一个新单元?
  329. + }
  330. + const {cellSize} = this;
  331. + const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
  332. + const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
  333. + const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
  334. + const voxelOffset = voxelY * cellSize * cellSize +
  335. + voxelZ * cellSize +
  336. + voxelX;
  337. + cell[voxelOffset] = v;
  338. + }
  339. getVoxel(x, y, z) {
  340. const cell = this.getCellForVoxel(x, y, z);
  341. if (!cell) {
  342. return 0;
  343. }
  344. const {cellSize} = this;
  345. const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
  346. const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
  347. const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
  348. const voxelOffset = voxelY * cellSize * cellSize +
  349. voxelZ * cellSize +
  350. voxelX;
  351. return cell[voxelOffset];
  352. }
  353. generateGeometryDataForCell(cellX, cellY, cellZ) {
  354. ...
  355. }
  356. </pre>
  357. <p>嗯……我注意到有很多重复的代码。让我们重构一下,提高代码复用性。</p>
  358. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class VoxelWorld {
  359. constructor(cellSize) {
  360. this.cellSize = cellSize;
  361. + this.cellSliceSize = cellSize * cellSize;
  362. this.cell = new Uint8Array(cellSize * cellSize * cellSize);
  363. }
  364. getCellForVoxel(x, y, z) {
  365. const {cellSize} = this;
  366. const cellX = Math.floor(x / cellSize);
  367. const cellY = Math.floor(y / cellSize);
  368. const cellZ = Math.floor(z / cellSize);
  369. if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
  370. return null;
  371. }
  372. return this.cell;
  373. }
  374. + computeVoxelOffset(x, y, z) {
  375. + const {cellSize, cellSliceSize} = this;
  376. + const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
  377. + const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
  378. + const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
  379. + return voxelY * cellSliceSize +
  380. + voxelZ * cellSize +
  381. + voxelX;
  382. + }
  383. setVoxel(x, y, z, v) {
  384. const cell = this.getCellForVoxel(x, y, z);
  385. if (!cell) {
  386. return; // TODO: 是否应添加一个新单元?
  387. }
  388. - const {cellSize} = this;
  389. - const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
  390. - const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
  391. - const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
  392. - const voxelOffset = voxelY * cellSize * cellSize +
  393. - voxelZ * cellSize +
  394. - voxelX;
  395. + const voxelOffset = this.computeVoxelOffset(x, y, z);
  396. cell[voxelOffset] = v;
  397. }
  398. getVoxel(x, y, z) {
  399. const cell = this.getCellForVoxel(x, y, z);
  400. if (!cell) {
  401. return 0;
  402. }
  403. - const {cellSize} = this;
  404. - const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
  405. - const voxelY = THREE.MathUtils.euclideanModulo(y, cellSize) | 0;
  406. - const voxelZ = THREE.MathUtils.euclideanModulo(z, cellSize) | 0;
  407. - const voxelOffset = voxelY * cellSize * cellSize +
  408. - voxelZ * cellSize +
  409. - voxelX;
  410. + const voxelOffset = this.computeVoxelOffset(x, y, z);
  411. return cell[voxelOffset];
  412. }
  413. generateGeometryDataForCell(cellX, cellY, cellZ) {
  414. ...
  415. }
  416. </pre>
  417. <p>现在我们来编写代码,用体素填充第一个单元。</p>
  418. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const cellSize = 32;
  419. const world = new VoxelWorld(cellSize);
  420. for (let y = 0; y &lt; cellSize; ++y) {
  421. for (let z = 0; z &lt; cellSize; ++z) {
  422. for (let x = 0; x &lt; cellSize; ++x) {
  423. const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
  424. if (y &lt; height) {
  425. world.setVoxel(x, y, z, 1);
  426. }
  427. }
  428. }
  429. }
  430. </pre>
  431. <p>接下来,我们编写实际生成几何体的代码,就像我们在 <a href="custom-buffergeometry.html">自定义 BufferGeometry 教程</a>中介绍的那样。</p>
  432. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const {positions, normals, indices} = world.generateGeometryDataForCell(0, 0, 0);
  433. const geometry = new THREE.BufferGeometry();
  434. const material = new THREE.MeshLambertMaterial({color: 'green'});
  435. const positionNumComponents = 3;
  436. const normalNumComponents = 3;
  437. geometry.setAttribute(
  438. 'position',
  439. new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
  440. geometry.setAttribute(
  441. 'normal',
  442. new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
  443. geometry.setIndex(indices);
  444. const mesh = new THREE.Mesh(geometry, material);
  445. scene.add(mesh);
  446. </pre>
  447. <p>让我们试试效果:</p>
  448. <p></p><div translate="no" class="threejs_example_container notranslate">
  449. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/editor.html?url=/manual/examples/voxel-geometry-culled-faces.html"></iframe></div>
  450. <a class="threejs_center" href="/manual/examples/voxel-geometry-culled-faces.html" target="_blank">点击此处,在新窗口中打开示例</a>
  451. </div>
  452. <p></p>
  453. <p>看起来已经正常工作了!接下来,我们添加纹理支持。</p>
  454. <p>在网上搜索后,我找到了一组由 <a href="https://www.minecraftforum.net/members/Joshtimus">Joshtimus</a> 制作的、采用 <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC-BY-NC-SA</a> 许可协议的 <a href="https://www.minecraftforum.net/forums/mapping-and-modding-java-edition/resource-packs/1245961-16x-1-7-4-wip-flourish">Minecraft 纹理资源包</a>。我随机挑选了几张贴图,并制作了如下的 <a href="https://www.google.com/search?q=texture+atlas">纹理图集(texture atlas)</a>。</p>
  455. <div class="threejs_center"><img class="checkerboard" src="../examples/resources/images/minecraft/flourish-cc-by-nc-sa.png" style="width: 512px; image-rendering: pixelated;"></div>
  456. <p>为了简化使用,这些纹理按“体素类型”排列成列,其中:</p>
  457. <ul>
  458. <li><strong>第一行</strong>:体素的侧面(left/right/front/back)</li>
  459. <li><strong>第二行</strong>:体素的顶部(top)</li>
  460. <li><strong>第三行</strong>:体素的底部(bottom)</li>
  461. </ul>
  462. <p>了解了图集结构后,我们可以向 <code class="notranslate" translate="no">VoxelWorld.faces</code> 数据中添加信息,指定每个面应使用的行(uvRow)以及对应的 UV 坐标。</p>
  463. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">VoxelWorld.faces = [
  464. { // 左面
  465. + uvRow: 0,
  466. dir: [ -1, 0, 0 ],
  467. corners: [
  468. - [ 0, 1, 0 ],
  469. - [ 0, 0, 0 ],
  470. - [ 0, 1, 1 ],
  471. - [ 0, 0, 1 ],
  472. + { pos: [ 0, 1, 0 ], uv: [ 0, 1 ] },
  473. + { pos: [ 0, 0, 0 ], uv: [ 0, 0 ] },
  474. + { pos: [ 0, 1, 1 ], uv: [ 1, 1 ] },
  475. + { pos: [ 0, 0, 1 ], uv: [ 1, 0 ] },
  476. ],
  477. },
  478. { // 右面
  479. + uvRow: 0,
  480. dir: [ 1, 0, 0 ],
  481. corners: [
  482. - [ 1, 1, 1 ],
  483. - [ 1, 0, 1 ],
  484. - [ 1, 1, 0 ],
  485. - [ 1, 0, 0 ],
  486. + { pos: [ 1, 1, 1 ], uv: [ 0, 1 ] },
  487. + { pos: [ 1, 0, 1 ], uv: [ 0, 0 ] },
  488. + { pos: [ 1, 1, 0 ], uv: [ 1, 1 ] },
  489. + { pos: [ 1, 0, 0 ], uv: [ 1, 0 ] },
  490. ],
  491. },
  492. { // 底面
  493. + uvRow: 1,
  494. dir: [ 0, -1, 0 ],
  495. corners: [
  496. - [ 1, 0, 1 ],
  497. - [ 0, 0, 1 ],
  498. - [ 1, 0, 0 ],
  499. - [ 0, 0, 0 ],
  500. + { pos: [ 1, 0, 1 ], uv: [ 1, 0 ] },
  501. + { pos: [ 0, 0, 1 ], uv: [ 0, 0 ] },
  502. + { pos: [ 1, 0, 0 ], uv: [ 1, 1 ] },
  503. + { pos: [ 0, 0, 0 ], uv: [ 0, 1 ] },
  504. ],
  505. },
  506. { // 顶面
  507. + uvRow: 2,
  508. dir: [ 0, 1, 0 ],
  509. corners: [
  510. - [ 0, 1, 1 ],
  511. - [ 1, 1, 1 ],
  512. - [ 0, 1, 0 ],
  513. - [ 1, 1, 0 ],
  514. + { pos: [ 0, 1, 1 ], uv: [ 1, 1 ] },
  515. + { pos: [ 1, 1, 1 ], uv: [ 0, 1 ] },
  516. + { pos: [ 0, 1, 0 ], uv: [ 1, 0 ] },
  517. + { pos: [ 1, 1, 0 ], uv: [ 0, 0 ] },
  518. ],
  519. },
  520. { // 背面
  521. + uvRow: 0,
  522. dir: [ 0, 0, -1 ],
  523. corners: [
  524. - [ 1, 0, 0 ],
  525. - [ 0, 0, 0 ],
  526. - [ 1, 1, 0 ],
  527. - [ 0, 1, 0 ],
  528. + { pos: [ 1, 0, 0 ], uv: [ 0, 0 ] },
  529. + { pos: [ 0, 0, 0 ], uv: [ 1, 0 ] },
  530. + { pos: [ 1, 1, 0 ], uv: [ 0, 1 ] },
  531. + { pos: [ 0, 1, 0 ], uv: [ 1, 1 ] },
  532. ],
  533. },
  534. { // 前面
  535. + uvRow: 0,
  536. dir: [ 0, 0, 1 ],
  537. corners: [
  538. - [ 0, 0, 1 ],
  539. - [ 1, 0, 1 ],
  540. - [ 0, 1, 1 ],
  541. - [ 1, 1, 1 ],
  542. + { pos: [ 0, 0, 1 ], uv: [ 0, 0 ] },
  543. + { pos: [ 1, 0, 1 ], uv: [ 1, 0 ] },
  544. + { pos: [ 0, 1, 1 ], uv: [ 0, 1 ] },
  545. + { pos: [ 1, 1, 1 ], uv: [ 1, 1 ] },
  546. ],
  547. },
  548. ];
  549. </pre>
  550. <p>然后我们更新生成几何体的代码,以使用这些 UV 数据。我们需要知道图集中每个纹理块的大小以及整个纹理图集的尺寸。</p>
  551. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class VoxelWorld {
  552. - constructor(cellSize) {
  553. - this.cellSize = cellSize;
  554. + constructor(options) {
  555. + this.cellSize = options.cellSize;
  556. + this.tileSize = options.tileSize;
  557. + this.tileTextureWidth = options.tileTextureWidth;
  558. + this.tileTextureHeight = options.tileTextureHeight;
  559. + const {cellSize} = this;
  560. + this.cellSliceSize = cellSize * cellSize;
  561. + this.cell = new Uint8Array(cellSize * cellSize * cellSize);
  562. }
  563. ...
  564. generateGeometryDataForCell(cellX, cellY, cellZ) {
  565. - const {cellSize} = this;
  566. + const {cellSize, tileSize, tileTextureWidth, tileTextureHeight} = this;
  567. const positions = [];
  568. const normals = [];
  569. + const uvs = [];
  570. const indices = [];
  571. const startX = cellX * cellSize;
  572. const startY = cellY * cellSize;
  573. const startZ = cellZ * cellSize;
  574. for (let y = 0; y &lt; cellSize; ++y) {
  575. const voxelY = startY + y;
  576. for (let z = 0; z &lt; cellSize; ++z) {
  577. const voxelZ = startZ + z;
  578. for (let x = 0; x &lt; cellSize; ++x) {
  579. const voxelX = startX + x;
  580. const voxel = this.getVoxel(voxelX, voxelY, voxelZ);
  581. if (voxel) {
  582. const uvVoxel = voxel - 1; // 体素 0 代表天空,因此 UV 从 0 开始
  583. // 这里有体素,但需要为其生成面吗?
  584. - for (const {dir, corners} of VoxelWorld.faces) {
  585. + for (const {dir, corners, uvRow} of VoxelWorld.faces) {
  586. const neighbor = this.getVoxel(
  587. voxelX + dir[0],
  588. voxelY + dir[1],
  589. voxelZ + dir[2]);
  590. if (!neighbor) {
  591. // 该方向无相邻体素,因此需要添加一个面
  592. const ndx = positions.length / 3;
  593. - for (const pos of corners) {
  594. + for (const {pos, uv} of corners) {
  595. positions.push(pos[0] + x, pos[1] + y, pos[2] + z);
  596. normals.push(...dir);
  597. + uvs.push(
  598. + (uvVoxel + uv[0]) * tileSize / tileTextureWidth,
  599. + 1 - (uvRow + 1 - uv[1]) * tileSize / tileTextureHeight);
  600. }
  601. indices.push(
  602. ndx, ndx + 1, ndx + 2,
  603. ndx + 2, ndx + 1, ndx + 3
  604. );
  605. }
  606. }
  607. }
  608. }
  609. }
  610. }
  611. return {
  612. positions,
  613. normals,
  614. uvs,
  615. indices
  616. };
  617. }
  618. }
  619. </pre>
  620. <p>接下来,我们需要 <a href="textures.html">加载纹理</a>。</p>
  621. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const loader = new THREE.TextureLoader();
  622. const texture = loader.load('resources/images/minecraft/flourish-cc-by-nc-sa.png', render);
  623. texture.magFilter = THREE.NearestFilter;
  624. texture.minFilter = THREE.NearestFilter;
  625. texture.colorSpace = THREE.SRGBColorSpace;
  626. </pre>
  627. <p>然后将相关参数传递给 <code class="notranslate" translate="no">VoxelWorld</code> 类</p>
  628. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">+const tileSize = 16;
  629. +const tileTextureWidth = 256;
  630. +const tileTextureHeight = 64;
  631. -const world = new VoxelWorld(cellSize);
  632. +const world = new VoxelWorld({
  633. + cellSize,
  634. + tileSize,
  635. + tileTextureWidth,
  636. + tileTextureHeight,
  637. +});
  638. </pre>
  639. <p>现在,我们实际在创建几何体时使用 UV 坐标,并在创建材质时使用纹理</p>
  640. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">-const {positions, normals, indices} = world.generateGeometryDataForCell(0, 0, 0);
  641. +const {positions, normals, uvs, indices} = world.generateGeometryDataForCell(0, 0, 0);
  642. const geometry = new THREE.BufferGeometry();
  643. -const material = new THREE.MeshLambertMaterial({color: 'green'});
  644. +const material = new THREE.MeshLambertMaterial({
  645. + map: texture,
  646. + side: THREE.DoubleSide,
  647. + alphaTest: 0.1,
  648. + transparent: true,
  649. +});
  650. const positionNumComponents = 3;
  651. const normalNumComponents = 3;
  652. +const uvNumComponents = 2;
  653. geometry.setAttribute(
  654. 'position',
  655. new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
  656. geometry.setAttribute(
  657. 'normal',
  658. new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
  659. +geometry.setAttribute(
  660. + 'uv',
  661. + new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
  662. geometry.setIndex(indices);
  663. const mesh = new THREE.Mesh(geometry, material);
  664. scene.add(mesh);
  665. </pre>
  666. <p>最后一件事:我们需要设置一些体素,使用不同的纹理。</p>
  667. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">for (let y = 0; y &lt; cellSize; ++y) {
  668. for (let z = 0; z &lt; cellSize; ++z) {
  669. for (let x = 0; x &lt; cellSize; ++x) {
  670. const height = (Math.sin(x / cellSize * Math.PI * 2) + Math.sin(z / cellSize * Math.PI * 3)) * (cellSize / 6) + (cellSize / 2);
  671. if (y &lt; height) {
  672. - world.setVoxel(x, y, z, 1);
  673. + world.setVoxel(x, y, z, randInt(1, 17));
  674. }
  675. }
  676. }
  677. }
  678. +function randInt(min, max) {
  679. + return Math.floor(Math.random() * (max - min) + min);
  680. +}
  681. </pre>
  682. <p>这样,我们就成功应用了纹理!</p>
  683. <p></p><div translate="no" class="threejs_example_container notranslate">
  684. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/voxel-geometry-culled-faces-with-textures.html"></iframe></div>
  685. <a class="threejs_center" href="/manual/examples/voxel-geometry-culled-faces-with-textures.html" target="_blank">点击此处,在新窗口中打开示例</a>
  686. </div>
  687. <p></p>
  688. <p>接下来,我们让程序支持多个体素单元(cell)。</p>
  689. <p>为此,我们将使用“单元 ID”来存储单元。单元 ID 就是单元坐标的字符串表示,用逗号分隔。例如,体素坐标 (35, 0, 0) 属于单元 (1, 0, 0),其 ID 为 <code class="notranslate" translate="no">"1,0,0"</code>。</p>
  690. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">class VoxelWorld {
  691. constructor(options) {
  692. this.cellSize = options.cellSize;
  693. this.tileSize = options.tileSize;
  694. this.tileTextureWidth = options.tileTextureWidth;
  695. this.tileTextureHeight = options.tileTextureHeight;
  696. const {cellSize} = this;
  697. this.cellSliceSize = cellSize * cellSize;
  698. - this.cell = new Uint8Array(cellSize * cellSize * cellSize);
  699. + this.cells = {};
  700. }
  701. + computeCellId(x, y, z) {
  702. + const {cellSize} = this;
  703. + const cellX = Math.floor(x / cellSize);
  704. + const cellY = Math.floor(y / cellSize);
  705. + const cellZ = Math.floor(z / cellSize);
  706. + return `${cellX},${cellY},${cellZ}`;
  707. + }
  708. + getCellForVoxel(x, y, z) {
  709. - const cellX = Math.floor(x / cellSize);
  710. - const cellY = Math.floor(y / cellSize);
  711. - const cellZ = Math.floor(z / cellSize);
  712. - if (cellX !== 0 || cellY !== 0 || cellZ !== 0) {
  713. - return null;
  714. - }
  715. - return this.cell;
  716. + return this.cells[this.computeCellId(x, y, z)];
  717. }
  718. ...
  719. }
  720. </pre>
  721. <p>现在我们可以修改 <code class="notranslate" translate="no">setVoxel</code> 方法:当尝试设置一个尚未存在的单元中的体素时,自动创建该单元。</p>
  722. <pre class="prettyprint showlinemods notranslate lang-js" translate="no"> setVoxel(x, y, z, v) {
  723. - const cell = this.getCellForVoxel(x, y, z);
  724. + let cell = this.getCellForVoxel(x, y, z);
  725. if (!cell) {
  726. - return 0;
  727. + cell = this.addCellForVoxel(x, y, z);
  728. }
  729. const voxelOffset = this.computeVoxelOffset(x, y, z);
  730. cell[voxelOffset] = v;
  731. }
  732. + addCellForVoxel(x, y, z) {
  733. + const cellId = this.computeCellId(x, y, z);
  734. + let cell = this.cells[cellId];
  735. + if (!cell) {
  736. + const {cellSize} = this;
  737. + cell = new Uint8Array(cellSize * cellSize * cellSize);
  738. + this.cells[cellId] = cell;
  739. + }
  740. + return cell;
  741. + }
  742. </pre>
  743. <p>让我们为场景添加可编辑功能。</p>
  744. <p>首先,我们添加一个用户界面(UI)。使用单选按钮(radio buttons),我们可以创建一个 8×2 的纹理选择面板:</p>
  745. <pre class="prettyprint showlinemods notranslate lang-html" translate="no">&lt;body&gt;
  746. &lt;canvas id="c"&gt;&lt;/canvas&gt;
  747. + &lt;div id="ui"&gt;
  748. + &lt;div class="tiles"&gt;
  749. + &lt;input type="radio" name="voxel" id="voxel1" value="1"&gt;&lt;label for="voxel1" style="background-position: -0% -0%"&gt;&lt;/label&gt;
  750. + &lt;input type="radio" name="voxel" id="voxel2" value="2"&gt;&lt;label for="voxel2" style="background-position: -100% -0%"&gt;&lt;/label&gt;
  751. + &lt;input type="radio" name="voxel" id="voxel3" value="3"&gt;&lt;label for="voxel3" style="background-position: -200% -0%"&gt;&lt;/label&gt;
  752. + &lt;input type="radio" name="voxel" id="voxel4" value="4"&gt;&lt;label for="voxel4" style="background-position: -300% -0%"&gt;&lt;/label&gt;
  753. + &lt;input type="radio" name="voxel" id="voxel5" value="5"&gt;&lt;label for="voxel5" style="background-position: -400% -0%"&gt;&lt;/label&gt;
  754. + &lt;input type="radio" name="voxel" id="voxel6" value="6"&gt;&lt;label for="voxel6" style="background-position: -500% -0%"&gt;&lt;/label&gt;
  755. + &lt;input type="radio" name="voxel" id="voxel7" value="7"&gt;&lt;label for="voxel7" style="background-position: -600% -0%"&gt;&lt;/label&gt;
  756. + &lt;input type="radio" name="voxel" id="voxel8" value="8"&gt;&lt;label for="voxel8" style="background-position: -700% -0%"&gt;&lt;/label&gt;
  757. + &lt;/div&gt;
  758. + &lt;div class="tiles"&gt;
  759. + &lt;input type="radio" name="voxel" id="voxel9" value="9" &gt;&lt;label for="voxel9" style="background-position: -800% -0%"&gt;&lt;/label&gt;
  760. + &lt;input type="radio" name="voxel" id="voxel10" value="10"&gt;&lt;label for="voxel10" style="background-position: -900% -0%"&gt;&lt;/label&gt;
  761. + &lt;input type="radio" name="voxel" id="voxel11" value="11"&gt;&lt;label for="voxel11" style="background-position: -1000% -0%"&gt;&lt;/label&gt;
  762. + &lt;input type="radio" name="voxel" id="voxel12" value="12"&gt;&lt;label for="voxel12" style="background-position: -1100% -0%"&gt;&lt;/label&gt;
  763. + &lt;input type="radio" name="voxel" id="voxel13" value="13"&gt;&lt;label for="voxel13" style="background-position: -1200% -0%"&gt;&lt;/label&gt;
  764. + &lt;input type="radio" name="voxel" id="voxel14" value="14"&gt;&lt;label for="voxel14" style="background-position: -1300% -0%"&gt;&lt;/label&gt;
  765. + &lt;input type="radio" name="voxel" id="voxel15" value="15"&gt;&lt;label for="voxel15" style="background-position: -1400% -0%"&gt;&lt;/label&gt;
  766. + &lt;input type="radio" name="voxel" id="voxel16" value="16"&gt;&lt;label for="voxel16" style="background-position: -1500% -0%"&gt;&lt;/label&gt;
  767. + &lt;/div&gt;
  768. + &lt;/div&gt;
  769. &lt;/body&gt;
  770. </pre>
  771. <p>再添加一些 CSS 样式,用于美化 UI、显示纹理图块,并高亮当前选中的项:</p>
  772. <pre class="prettyprint showlinemods notranslate lang-css" translate="no">body {
  773. margin: 0;
  774. }
  775. #c {
  776. width: 100%;
  777. height: 100%;
  778. display: block;
  779. }
  780. +#ui {
  781. + position: absolute;
  782. + left: 10px;
  783. + top: 10px;
  784. + background: rgba(0, 0, 0, 0.8);
  785. + padding: 5px;
  786. +}
  787. +#ui input[type=radio] {
  788. + width: 0;
  789. + height: 0;
  790. + display: none;
  791. +}
  792. +#ui input[type=radio] + label {
  793. + background-image: url('resources/images/minecraft/flourish-cc-by-nc-sa.png');
  794. + background-size: 1600% 400%;
  795. + image-rendering: pixelated;
  796. + width: 64px;
  797. + height: 64px;
  798. + display: inline-block;
  799. +}
  800. +#ui input[type=radio]:checked + label {
  801. + outline: 3px solid red;
  802. +}
  803. +@media (max-width: 600px), (max-height: 600px) {
  804. + #ui input[type=radio] + label {
  805. + width: 32px;
  806. + height: 32px;
  807. + }
  808. +}
  809. </pre>
  810. <p>用户体验将如下所示:如果没有选择任何方块并点击一个体素,该体素将被删除;或者,如果点击一个体素并按住 Shift 键,它也会被删除。否则,如果选择了一个方块,它将被添加。你可以再次点击已选中的方块类型来取消选择。</p>
  811. <p>下面的代码可以让用户取消选中的单选按钮。</p>
  812. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">let currentVoxel = 0;
  813. let currentId;
  814. document.querySelectorAll('#ui .tiles input[type=radio][name=voxel]').forEach((elem) =&gt; {
  815. elem.addEventListener('click', allowUncheck);
  816. });
  817. function allowUncheck() {
  818. if (this.id === currentId) {
  819. this.checked = false;
  820. currentId = undefined;
  821. currentVoxel = 0;
  822. } else {
  823. currentId = this.id;
  824. currentVoxel = parseInt(this.value);
  825. }
  826. }
  827. </pre>
  828. <p>下面的代码会根据用户点击的位置放置体素。它使用了类似我们在 <a href="picking.html">拾取那篇文章</a> 中的代码,但不是用内置的 <code class="notranslate" translate="no">RayCaster</code>,而是用 <code class="notranslate" translate="no">VoxelWorld.intersectRay</code>,它返回交点的位置和被击中的面的法线。</p>
  829. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">function getCanvasRelativePosition(event) {
  830. const rect = canvas.getBoundingClientRect();
  831. return {
  832. x: (event.clientX - rect.left) * canvas.width / rect.width,
  833. y: (event.clientY - rect.top ) * canvas.height / rect.height,
  834. };
  835. }
  836. function placeVoxel(event) {
  837. const pos = getCanvasRelativePosition(event);
  838. const x = (pos.x / canvas.width ) * 2 - 1;
  839. const y = (pos.y / canvas.height) * -2 + 1; // 注意这里 Y 要翻转
  840. const start = new THREE.Vector3();
  841. const end = new THREE.Vector3();
  842. start.setFromMatrixPosition(camera.matrixWorld);
  843. end.set(x, y, 1).unproject(camera);
  844. const intersection = world.intersectRay(start, end);
  845. if (intersection) {
  846. const voxelId = event.shiftKey ? 0 : currentVoxel;
  847. // 交点位于面上,这意味着数学精度问题可能会让我们位于面的任一侧
  848. // 如果是删除(currentVoxel = 0),则沿法线方向进入体素一半
  849. // 如果是添加(currentVoxel > 0),则沿法线方向离开体素一半
  850. const pos = intersection.position.map((v, ndx) =&gt; {
  851. return v + intersection.normal[ndx] * (voxelId &gt; 0 ? 0.5 : -0.5);
  852. });
  853. world.setVoxel(...pos, voxelId);
  854. updateVoxelGeometry(...pos);
  855. requestRenderIfNotRequested();
  856. }
  857. }
  858. const mouse = {
  859. x: 0,
  860. y: 0,
  861. };
  862. function recordStartPosition(event) {
  863. mouse.x = event.clientX;
  864. mouse.y = event.clientY;
  865. mouse.moveX = 0;
  866. mouse.moveY = 0;
  867. }
  868. function recordMovement(event) {
  869. mouse.moveX += Math.abs(mouse.x - event.clientX);
  870. mouse.moveY += Math.abs(mouse.y - event.clientY);
  871. }
  872. function placeVoxelIfNoMovement(event) {
  873. if (mouse.moveX &lt; 5 &amp;&amp; mouse.moveY &lt; 5) {
  874. placeVoxel(event);
  875. }
  876. window.removeEventListener('pointermove', recordMovement);
  877. window.removeEventListener('pointerup', placeVoxelIfNoMovement);
  878. }
  879. canvas.addEventListener('pointerdown', (event) =&gt; {
  880. event.preventDefault();
  881. recordStartPosition(event);
  882. window.addEventListener('pointermove', recordMovement);
  883. window.addEventListener('pointerup', placeVoxelIfNoMovement);
  884. }, {passive: false});
  885. canvas.addEventListener('touchstart', (event) =&gt; {
  886. // 阻止滚动
  887. event.preventDefault();
  888. }, {passive: false});
  889. </pre>
  890. <p>上面的代码做了很多事。基本上,鼠标有双重用途:一是移动相机,二是编辑世界。当你松开鼠标时,如果在按下鼠标后没有移动它,就会放置/删除一个体素。这是假设如果你移动了鼠标,你是想移动相机而不是放置方块。<code class="notranslate" translate="no">moveX</code> 和 <code class="notranslate" translate="no">moveY</code> 是绝对移动距离,所以如果你向左移动 10 然后再向右移动 10,总共移动了 20 个单位。这种情况下,用户很可能只是来回旋转模型,而不想放置方块。我没有测试 <code class="notranslate" translate="no">5</code> 这个范围是否合适。</p>
  891. <p>在代码中我们调用 <code class="notranslate" translate="no">world.setVoxel</code> 来设置一个体素,然后调用 <code class="notranslate" translate="no">updateVoxelGeometry</code> 来根据变化更新 three.js 的几何体。</p>
  892. <p>我们现在来实现它。如果用户点击了单元格边缘的体素,那么相邻单元格的几何体可能也需要更新。这意味着我们需要检查刚刚编辑的体素所在的单元格,以及该单元格在 6 个方向上的相邻单元格。</p>
  893. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const neighborOffsets = [
  894. [ 0, 0, 0], // 自身
  895. [-1, 0, 0], // 左
  896. [ 1, 0, 0], // 右
  897. [ 0, -1, 0], // 下
  898. [ 0, 1, 0], // 上
  899. [ 0, 0, -1], // 后
  900. [ 0, 0, 1], // 前
  901. ];
  902. function updateVoxelGeometry(x, y, z) {
  903. const updatedCellIds = {};
  904. for (const offset of neighborOffsets) {
  905. const ox = x + offset[0];
  906. const oy = y + offset[1];
  907. const oz = z + offset[2];
  908. const cellId = world.computeCellId(ox, oy, oz);
  909. if (!updatedCellIds[cellId]) {
  910. updatedCellIds[cellId] = true;
  911. updateCellGeometry(ox, oy, oz);
  912. }
  913. }
  914. }
  915. </pre>
  916. <p>我本来打算这样检查相邻单元格:</p>
  917. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const voxelX = THREE.MathUtils.euclideanModulo(x, cellSize) | 0;
  918. if (voxelX === 0) {
  919. // 更新左边的单元格
  920. } else if (voxelX === cellSize - 1) {
  921. // 更新右边的单元格
  922. }
  923. </pre>
  924. <p>并且为另外 4 个方向再加 4 次检查,但我想到直接用一个偏移数组,并保存已更新过的单元格 ID,代码会更简单。如果更新的体素不在单元格边缘,测试会很快跳过更新同一个单元格。</p>
  925. <p>对于 <code class="notranslate" translate="no">updateCellGeometry</code>,我们将直接使用之前生成一个单元格几何体的代码,并让它支持处理多个单元格。</p>
  926. <pre class="prettyprint showlinemods notranslate lang-js" translate="no">const cellIdToMesh = {};
  927. function updateCellGeometry(x, y, z) {
  928. const cellX = Math.floor(x / cellSize);
  929. const cellY = Math.floor(y / cellSize);
  930. const cellZ = Math.floor(z / cellSize);
  931. const cellId = world.computeCellId(x, y, z);
  932. let mesh = cellIdToMesh[cellId];
  933. const geometry = mesh ? mesh.geometry : new THREE.BufferGeometry();
  934. const {positions, normals, uvs, indices} = world.generateGeometryDataForCell(cellX, cellY, cellZ);
  935. const positionNumComponents = 3;
  936. geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), positionNumComponents));
  937. const normalNumComponents = 3;
  938. geometry.setAttribute('normal', new THREE.BufferAttribute(new Float32Array(normals), normalNumComponents));
  939. const uvNumComponents = 2;
  940. geometry.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(uvs), uvNumComponents));
  941. geometry.setIndex(indices);
  942. geometry.computeBoundingSphere();
  943. if (!mesh) {
  944. mesh = new THREE.Mesh(geometry, material);
  945. mesh.name = cellId;
  946. cellIdToMesh[cellId] = mesh;
  947. scene.add(mesh);
  948. mesh.position.set(cellX * cellSize, cellY * cellSize, cellZ * cellSize);
  949. }
  950. }
  951. </pre>
  952. <p>上面的代码会检查单元格 ID 到网格的映射。如果我们请求的单元格不存在,就会创建一个新的 <a href="/docs/#api/en/objects/Mesh"><code class="notranslate" translate="no">Mesh</code></a> 并放到世界空间的正确位置。最后,我们用新数据更新属性和索引。</p>
  953. <div translate="no" class="threejs_example_container notranslate">
  954. <div><iframe class="threejs_example notranslate" translate="no" style=" " src="/manual/examples/resources/editor.html?url=/manual/examples/voxel-geometry-culled-faces-ui.html"></iframe></div>
  955. <a class="threejs_center" href="/manual/examples/voxel-geometry-culled-faces-ui.html" target="_blank">点击这里在新窗口中打开</a>
  956. </div>
  957. <p>一些注意事项:</p>
  958. <p><code class="notranslate" translate="no">RayCaster</code> 可能也能很好地工作,我没试过。我找到的是一个<a href="https://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.42.3443&rep=rep1&type=pdf">针对体素优化的光线投射器</a>。</p>
  959. <p>我把 <code class="notranslate" translate="no">intersectRay</code> 做成了 VoxelWorld 的一部分,因为如果它太慢,我们可以先对单元格进行光线投射,再对体素进行光线投射,作为一种简单的加速方式。</p>
  960. <p>你可能需要修改光线投射的长度,因为目前它会一直到 Z-far。我猜如果用户点击了很远的地方,他们并不是真的想在世界另一端的 1、2 像素大的位置放方块。</p>
  961. <p>调用 <code class="notranslate" translate="no">geometry.computeBoundingSphere</code> 可能会比较慢。我们可以直接手动设置包围球以适配整个单元格。</p>
  962. <p>当一个单元格里的所有体素都是 0 时,我们是否要移除这个单元格?如果要发布这个功能,这可能是一个合理的优化。</p>
  963. <p>考虑这个工作的方式,最糟糕的情况是一个开关体素交错的棋盘格。我暂时不知道在性能太慢时可以用什么其他策略。也许性能慢了会促使用户不要去做超大棋盘格。</p>
  964. <p>为了简单起见,纹理图集是每种方块类型占用 1 列。更好的做法是制作一个更灵活的结构,让每种方块类型可以指定它的面纹理在图集中的位置。现在这种方式浪费了很多空间。</p>
  965. <p>看看真正的 Minecraft,会发现有些方块不是立方体,比如栅栏或花。这种情况下,我们需要一个方块类型表,每种方块要记录它是立方体还是其他几何形状。如果不是立方体,那么在生成几何体时的邻居检测也需要改变。例如花方块旁边的另一个方块不应该移除它们之间的面。</p>
  966. <p>如果你想用 three.js 做一个类 Minecraft 的东西,希望这些内容能给你一些起步思路,以及如何生成相对高效的几何体。</p>
  967. <p><canvas id="c"></canvas></p>
  968. <script type="module" src="../resources/threejs-voxel-geometry.js"></script>
  969. </div>
  970. </div>
  971. </div>
  972. <script src="../resources/prettify.js"></script>
  973. <script src="../resources/lesson.js"></script>
  974. </body></html>
粤ICP备19079148号