Three.js体素化原理及实现

在本文中,我们探索了 3D 模型的体素化过程,重点是使用导入的 glTF 模型创建 3D 像素艺术。 本文包括一个最终演示,涵盖了可以使用体素化实现的各种 3D 效果。 我们将提供涵盖以下主题的分步指南:

  • 确定 XYZ 坐标是否在 3D 网格内的数学方法
  • 将标准 Three.js 几何体分解为体素
  • 导入的 glTF 模型的实现
  • 体素化方法的局限性和优化
  • 设置体素样式和动画的方法

1、体素表示

体素表示的概念涉及跟踪 3D 网格并使用称为体素(Voxel)的基本元素填充其体积。 虽然体素是“体积像素”的缩写,但通常被渲染为 3D 框,而任何 3D 形状都可以用作艺术品中的体素。

在我们使用体素样式之前,我们需要知道如何将形状分解为体素,即如何在网格内定位一组 XYZ 坐标。

2、在网格内定位坐标

有一个优雅的技巧可以确定一个点是否在封闭的 3D 网格内。 当其表面没有孔或间隙时,网格被认为是“封闭的”。 不要将此与网格本身的复杂性混淆,只要其表面是连续的,网格可能有各种孔洞。

左:开放网格; 右:闭合网格。

让我们测试一个随机的 XYZ 坐标(蓝色框)是否在甜甜圈形状内。 为此,我们从 XYZ 坐标投射一条射线,计算射线与网格表面相交的次数,并检查次数是否为奇数。

如果框在网格内部,则交点数为奇数

为什么奇怪? 想象沿着射线从远处移动到 XYZ 点。 我们从网格外开始。 在第一个交点之后,我们进入网格体; 经过第二个路口,我们退出; 经过第三个路口,我们再次进入; 等等。 如果交点的数量是偶数(包括零),则该点在网格之外。 如果交叉点的数量是奇数,则该点在网格内部。 就如此容易!

实际上,我们计算从 XYZ 到无穷大的交点。 通过倒数更容易解释这个技巧,但无论计数方向如何,交叉点的数量都是相同的。 通常,光线的方向也无关紧要。 如果网格是封闭的,我们可以将射线从 XYZ 投射到任何地方并得到正确的结果。 唯一的例外是光线平行于网格表面投射时; 在这种情况下,我们将无法注册交点。

3、Three.js 中的实现

我们首先创建一个基本的 3D 场景,其中包含一些灯光、接收阴影的地板平面、渐变背景、轨道控件和用于场景设置的 GUI 模块。 下面场景中唯一不符合标准的是包含灯光和底部面板的 lightHolder 组。 通常,我们使用 OrbitControls 围绕静态场景旋转相机。 要创建旋转物体和静态相机的错觉,我们可以在 OrbitControls 更新时让 lightHolder 与相机一起旋转。 这是对 Three.js 样板的一个很好的补充,但与体素化无关。

接下来,我们添加要分解为体素的网格。 它可以是 Sphere、Torus、Torus Knot 或任何其他闭合网格(例如,LatheGeometry 是开放的,需要更多的工作来处理)。

首先,我们需要可以在网格内部的 XYZ 坐标。 为了获得它们,我们使用预定义的网格步穿越网格边界框。 对于像素式体素,我们使用简单的方形网格,但坐标采样可以是随机函数或更高级的东西。

function voxelizeMesh(mesh) {
    const boundingBox = new THREE.Box3().setFromObject(mesh);

    for (let i = box.min.x; i < box.max.x; i += params.gridSize) {
        for (let j = box.min.y; j < box.max.y; j += params.gridSize) {
            for (let k = box.min.z; k < box.max.z; k += params.gridSize) {
                const pos = new THREE.Vector3(i, j, k);
                if (isInsideMesh(pos, mesh)) {
                    voxels.push({
                        position: pos
                    })
                }
            }
        }
    }
}

一旦我们有了可能位于网格内部的候选坐标列表,我们就可以使用 THREE.Raycaster 从每个坐标向负 Y 方向投射一条射线,并计算交叉点的数量。 如果交叉点的数量是奇数,我们将坐标保存到体素数组中。

function isInsideMesh(pos, mesh) {
    rayCaster.set(pos, {x: 0, y: -1, z: 0});
    rayCasterIntersects = rayCaster.intersectObject(mesh, false);
    // we need odd number of intersections
    return rayCasterIntersects.length % 2 === 1; 
}
重要说明:默认情况下,任何 Three.js 材质都设置为仅渲染网格的正面。 这不仅会影响网格的外观,还会影响光线投射器的行为。 当材质的侧面属性设置为 THREE.Front 时,光线投射器只能检测与从外部观察的网格表面的交点。 为了从网格内部和外部检测交叉点,我们需要将材质的边属性更改为 THREE.DoubleSide,即使原始网格不会被渲染。
const outerShapeGeometry = geometries[params.geometry];
const outerShapeMaterial = new THREE.MeshLambertMaterial({
    color: 0xffff55,
    side: THREE.DoubleSide
});
outerShapeMesh = new THREE.Mesh(outerShapeGeometry, outerShapeMaterial);

一旦我们在体素数组中有了 XYZ 位置,就可以使用它们来放置体素。

要绘制体素,需要大量具有相同几何形状和材料但位置不同的对象。 这是 Three.js 实例化网格(Instanced Mesh)的完美用例。 使用实例化网格,可以实现出色的性能,同时仍然能够为每个体素设置颜色和变换。

我们可以创建一个 THREE.InstancedMesh,其实例数等于 voxels.length。 请记住以下几点:

  • 为了获得漂亮的像素化外观,使用了 RoundedBoxGeometry。 但是,它不包含在基本的 Three.js 构建中,必须手动导入。
  • 体素颜色现在可以简单地设置为材质的属性,但稍后将对颜色做更多的工作。
  • 让所有体素投射和接收阴影对于性能来说是相当昂贵的,但对于这个项目中的体素数量来说仍然是可以接受的。
import {RoundedBoxGeometry} from 'three/addons/geometries/RoundedBoxGeometry.js';

voxelGeometry = new RoundedBoxGeometry(params.boxSize, params.boxSize, params.boxSize, 2, params.boxRoundness);
voxelMaterial = new THREE.MeshLambertMaterial({
    color: new THREE.Color(0xffff55)
});
instancedMesh = new THREE.InstancedMesh(voxelGeometry, voxelMaterial, voxels.length);
instancedMesh.castShadow = true;
instancedMesh.receiveShadow = true;
scene.add(instancedMesh);

将体素数组的 XYZ 位置设置为实例的标准方法涉及使用哑(dummy)对象:

dummy = new THREE.Object3D();

for (let i = 0; i < voxels.length; i++) {
    dummy.position.copy(voxels[i].position);
    dummy.updateMatrix();
    instancedMesh.setMatrixAt(i, dummy.matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true

就是这样! 请随时查看这里的完整代码:

4、对导入的模型进行体素化

要对导入场景的.gltf 或 .glb 等格式的模型进行体素化,我们需要以与上述相同的方式跟踪场景的每个网格。 我们将加载场景中的所有网格收集到 importedMeshes 数组中。 即使它们不会被添加到场景中进行渲染,我们仍然需要将材质面更改为 THREE.DoubleSide,以便光线投射器正确检测由内到外和由外到内的交叉点。

我们还需要重新缩放场景以适应预定义的网格大小和场景参数,例如相机、灯光和地板位置。 由于我们不知道导入模型的大小,我们将其缩放以匹配 modelSize 值并确保它以原点 (0, 0, 0) 为中心。

function voxelizeModel(importedScene) {

    const importedMeshes = [];
    importedScene.traverse((child) => {
        if (child instanceof THREE.Mesh) {
            child.material.side = THREE.DoubleSide;
            importedMeshes.push(child);
        }
    });

    // get the size of loaded model
    let boundingBox = new THREE.Box3().setFromObject(importedScene);
    const size = boundingBox.getSize(new THREE.Vector3());
    const scaleFactor = params.modelSize / size.length();
    const center = boundingBox.getCenter(new THREE.Vector3()).multiplyScalar(-scaleFactor);

    // scale model to the standard size and center it
    importedScene.scale.multiplyScalar(scaleFactor);
    importedScene.position.copy(center);

    // recalculate the box for voxelization
    boundingBox = new THREE.Box3().setFromObject(importedScene);

    // go through the grid
    for (let i = boundingBox.min.x; i < boundingBox.max.x; i += params.gridSize) {
        for (let j = boundingBox.min.y; j < boundingBox.max.y; j += params.gridSize) {
            for (let k = boundingBox.min.z; k < boundingBox.max.z; k += params.gridSize) {
                // check if XYZ position is inside one of the meshes
                for (let meshCnt = 0; meshCnt < importedMeshes.length; meshCnt++) {
                    const pos = new THREE.Vector3(i, j, k);
                    const mesh = importedMeshes[meshCnt];

                    // add pos to voxels array if it's inside the mesh
                }
            }
        }
    }
}

我们还想从加载的模型中提取颜色并将它们与 XYZ 坐标一起保存。 这可以通过收集 mesh.material.color 属性来完成。

此外,为了统一调色板,我通过将颜色转换为 HSL 颜色空间、降低饱和度和增加亮度来调整颜色。 这为来自不同导入模型的颜色创建了相似的柔和风格,在同一页面上显示时看起来更好。

// no need to specify the color for instanced mesh material
voxelMaterial = new THREE.MeshLambertMaterial({});

function voxelizeModel(importedScene) {

    // ...
    
    const mesh = importedMeshes[meshCnt];
    const color = new THREE.Color();
    const {h, s, l} = mesh.material.color.getHSL(color);
    color.setHSL(h, s * .8, l * .8 + .2);

    if (isInsideMesh(pos, mesh)) {
        voxels.push({color: color, position: pos});
    }
} 

function recreateVoxels() {
    for (let i = 0; i < voxels.length; i++) {

        // add voxel color to each instance
        instancedMesh.setColorAt(i, voxels[i].color);

        dummy.position.copy(voxels[i].position);
        dummy.updateMatrix();
        instancedMesh.setMatrixAt(i, dummy.matrix);
    }
    instancedMesh.instanceColor.needsUpdate = true; // apply the colors
    instancedMesh.instanceMatrix.needsUpdate = true;
}

很好! 我们有一个适用于导入 3D 模型的有效体素化解决方案。

5、已知的局限性

如果你在网上冲浪并针对不同的模型(例如我最爱的poly.pizza)尝试上面的代码,可能会看到某些模型的意外结果。 以下是可能面临的常见问题:

5.1 开放的网格

这些模型不是为体素化而设计的。 3D 艺术家在优化他们的模型和移除隐藏的面孔方面做得很好。 但是一旦隐藏的面被移除,我们就会得到开放的网格,我们的光线投射算法会产生错误的结果。 可能的解决方案是在 Blender 中封闭表面或使用光线方向和其他参数。

例如,在左图中,我们将鳄梨模型体素化,光线投射到负 Y 方向,而在右图中,光线朝向正 Y 方向。

5.2 薄壁网格

同一个模型在不同的体素网格下看起来可能完全不同,特别是如果模型有一些难以用网格点“捕捉”的薄元素。 调整单元格大小和边界框偏移以获得模型的良好输出——即使很小的变化也会给你带来截然不同的结果。

例如,这个鸡蛋模型的蛋白部分非常薄,所以从第一次运行开始,我们看不到它。 但是,如果你开始增加边界框的垂直偏移,可以找到一个很好的拟合并正确地对网格进行体素化。

5.3 复杂的材质

有时,单个网格具有多种材料的组合或在其上映射了纹理。 在这两种情况下,我们都不能采用 mesh.material.color,因此应手动设置体素颜色。

6、风格变化

在完成 3D 像素演示之前,让我们考虑一下我们可以从体素化樱桃模型中获得的其他有趣效果。

将体素几何形状从圆框更改为乐高积木,并沿 Y 轴调整网格步长:

或者将盒子切换为球体并让它们四处移动:

你可以通过尝试不同的体素几何体来进一步进行体素化,例如将球体设计为气泡并让它们破裂,或者用平面替换盒子几何体并在其上映射 alpha 纹理。 在之前的教程中已经创建了类似的视觉效果。

此外,请记住,你可以对来自不同网格的粒子应用不同的动画。 还可以添加物理以使体素下落和碰撞,或使它们与光标排斥。 可能性是无止境!

7、优化体素

在最后的演示中,体素被创建为大小等于网格单元格大小的框。 然而,体素是背靠背站立的,这意味着有时我们会生成一堆永远不可见的体素。 这会影响性能并增加内存使用量。

让我们考虑一下这个红苹果的体素化。 它有一些小细节,所以我们选择一个相对较小的网格。

体素的总数可能非常大,但其中许多位于网格内部并且永远不可见。 为了优化这一点,我们可以通过两种方式修改光线投射算法:

  • 用沿正负 X、Y 和 Z 轴的六条射线替换单条射线。
  • 保存体素坐标不仅是在网格内部(具有奇数个交点),而且如果它靠近网格表面(与第一个交点的距离很小)。
体素数优化的苹果
function voxelizeModel(importedScene) {

    // ...

    for (let meshCnt = 0; meshCnt < importedMeshes.length; meshCnt++) {

        const mesh = importedMeshes[meshCnt];
        const pos = new THREE.Vector3(i, j, k);
        const color = new THREE.Color();
        const {h, s, l} = mesh.material.color.getHSL(color);
        color.setHSL(h, s * .8, l * .8 + .2);

        // this is bad for performance
        if (isInsideMesh(pos, {x: 0, y: 0, z: 1}, mesh)) {
            voxels.push({color: color, position: pos});
            break;
        } else if (isInsideMesh(pos, {x: 0, y: 0, z: -1}, mesh)) {
            voxels.push({color: color, position: pos});
            break;
        } else if (isInsideMesh(pos, {x: 0, y: 1, z: 0}, mesh)) {
            voxels.push({color: color, position: pos});
            break;
        } else if (isInsideMesh(pos, {x: 0, y: -1, z: 0}, mesh)) {
            voxels.push({color: color, position: pos});
            break;
        } else if (isInsideMesh(pos, {x: 1, y: 0, z: 0}, mesh)) {
            voxels.push({color: color, position: pos});
            break;
        } else if (isInsideMesh(pos, {x: -1, y: 0, z: 0}, mesh)) {
            voxels.push({color: color, position: pos});
            break;
        }
    }

    // ...

}

function isInsideMesh(pos, dir, mesh) {
    rayCaster.set(pos, dir);
    rayCasterIntersects = rayCaster.intersectObject(mesh, false);

    // return rayCasterIntersects.length % 2 === 1;
    return (rayCasterIntersects.length % 2 === 1 && rayCasterIntersects[0].distance <= 1.5 * params.gridSize);
}

对于这个苹果模型,我们可以在没有任何视觉变化的情况下将体素总数从 13472 减少到 3677。 另外,由于距离限制,我们可以通过这种方式处理具有平行于世界轴的开放网格和表面的模型。

但是,当使用 THREE.Raycaster 时,此方法的计算量非常大。 对于复杂的光线投射,需要边界体积层次结构 (BVH),可以使用像 three-mesh-bvh 这样的工具来管理它。 尽管如此,出于本教程的目的,我们将保持简单并使用单射线方法,其中射线朝向正 Z(因为它与所使用的模型集一起工作得更好)。

8、组合多个模型

要在同一页面上组合多个体素化模型,我们可以简单地为每个模型创建单独的容器并将它们添加到场景中。 我们还可以创建按钮或其他控件来在模型之间切换。 在这种情况下,所有模型都是从 poly.pizza 下载的 .glb 文件,并在没有任何更改的情况下使用。

9、多个场景

除了主场景,我们还有模型选择器。 在侧面板上,我们渲染了所有 3D 模型,每个模型都有自己的小场景。 侧面场景是相同的,我们只是更改背景颜色并添加 GLTF 模型,确保它们以相同的方式缩放和居中。 每个侧面场景都是一个单独的 THREE.Scene,具有自己的相机和轨道控件。

对于像素艺术视图,我们有一个主要的 THREE.Scene,其设置与前面的示例完全相同。

所有场景共享相同的  元素和相同的 THREE.Renderer。 THREE.Renderer 有非常方便的 .setViewport 和 .setScissor 方法来处理多个场景。 我们基本上需要:

这是一种广泛使用的技术,并不特定于体素化。 可以在这个仓库中找到完整的代码,或者也可以参考用作此方法模板的 Three.js 示例

10、多个模型

对于体素,我们仍然可以使用单个 THREE.InstancedMesh 对象。 我们以与以前相同的方式加载、缩放和追踪所有模型到体素。 然而,我们不是将体素的位置和颜色直接存储在体素数组中,而是将它们存储在新的二维 voxelsPerModel 数组中。 体素数组现在仅保留用于渲染的当前体素。

function loadModels() {

    recreateInstancedMesh(100);

    const loader = new GLTFLoader();
    let modelsLoadCnt = 0;
    modelURLs.forEach((url, modelIdx) => {

        // prepare <div> and Three.js scene for model preview
        const scene = createPreviewScene(modelIdx);
        previewScenes.push(scene);

        // load .glb file
        loader.load(url, (gltf) => {

            // add scaled and centered model to the preview panel;
            addModelToPreview(modelIdx, gltf.scene)

            // get the voxel data from the model
            voxelizeModel(modelIdx, gltf.scene);
            
            // recreate the instanced mesh with new size
            const numberOfInstances = Math.max(...voxelsPerModel.map(m => m.length));
            if (numberOfInstances > instancedMesh.count) {
                recreateInstancedMesh(numberOfInstances);
            }

            // once all the models are loaded...
            modelsLoadCnt++;
            if (modelsLoadCnt === 1) {
                // Once we have once voxelized model ready, start rendering the available content
                updateSceneSize();
                render();
            }
            if (modelsLoadCnt === modelURLs.length) {
                // Once we have all the models voxelized, start available content
                animateVoxels(0, activeModelIdx);
                setupSelectorEvents();
            }
        }, undefined, (error) => {
            console.error(error);
        });
    })
}

在上面的代码中,我们在加载和跟踪每个下一个模型时重新创建体素数组和 THREE.InstancedMesh 元素。 或者,我们可以等待所有模型完成,使用 Math.max(...voxelsPerModel.map(m => m.length)) 检查体素总数,然后只创建一次 THREE.InstancedMesh。 但是,我们不想在计算过程中看到空白屏幕,所以在开始时显示一些随机框会更好:

function recreateInstancedMesh(cnt) {

    // remove the old mesh and voxels data
    voxels = [];
    mainScene.remove(instancedMesh);

    // re-initiate the voxel array with random colors and positions
    for (let i = 0; i < cnt; i++) {
        const randomCoordinate = () => {
            let v = (Math.random() - .5);
            v -= (v % params.gridSize);
            return v;
        }
        voxels.push({
            position: new THREE.Vector3(randomCoordinate(), randomCoordinate(), randomCoordinate()),
            color: new THREE.Color().setHSL(Math.random(), .8, .8)
        })
    }
    
    // create a new instanced mesh object
    instancedMesh = new THREE.InstancedMesh(voxelGeometry, voxelMaterial, cnt);
    instancedMesh.castShadow = true;
    instancedMesh.receiveShadow = true;

    // assign voxels data to the instanced mesh
    for (let i = 0; i < cnt; i++) {
        instancedMesh.setColorAt(i, voxels[i].color);
        dummy.position.copy(voxels[i].position);
        dummy.updateMatrix();
        instancedMesh.setMatrixAt(i, dummy.matrix);
    }
    instancedMesh.instanceMatrix.needsUpdate = true;
    instancedMesh.instanceColor.needsUpdate = true;

    // add a new mesh to the scene
    mainScene.add(instancedMesh);
}

11、动画

现在所有模型都已准备就绪,我们拥有动画所需的一切:

  • instancedMesh:每个模型都适合的网格(实例数等于最大可能的体素数)。
  • voxelsPerModel:保存每个模型的颜色和位置的数组
  • 体素:保存体素框当前颜色和位置的数组

为了将体素从一个 3D 模型动画化到另一个 3D 模型,我们使用 animateVoxels() 函数,其中所有位置属性(.x、.y 和 .z)和所有颜色属性(.r、.g 和 .b)都转换为 新的价值观。 多亏了 GSAP 库,我们可以轻松地为每个过渡设置延迟、持续时间和缓动函数。 在每个过渡帧(onUpdate 回调)中,我们更新所有网格实例的属性,就像我们之前对静态模型所做的那样。 然后,我们使用新属性更新实例化网格,并确保在过渡结束时可见正确数量的实例 (instancedMesh.count)。 我还发现在过渡时旋转模型很好。

function animateVoxels(oldModelIdx, newModelIdx) {

    // animate voxels data
    for (let i = 0; i < voxels.length; i++) {
        
        gsap.killTweensOf(voxels[i].color);
        gsap.killTweensOf(voxels[i].position);

        const duration = .6 + .6 * Math.pow(Math.random(), 6);
        let targetPos;

        // move to new position if we have one;
        // otherwise, move to a randomly selected existing position
        //
        // animate to new color if it's determined
        // otherwise, voxel will be just hidden by animation of instancedMesh.count

        if (voxelsPerModel[newModelIdx][i]) {
            targetPos = voxelsPerModel[newModelIdx][i].position;
            gsap.to(voxels[i].color, {
                delay: .7 * Math.random() * duration,
                duration: .05,
                r: voxelsPerModel[newModelIdx][i].color.r,
                g: voxelsPerModel[newModelIdx][i].color.g,
                b: voxelsPerModel[newModelIdx][i].color.b,
                ease: "power1.in",
                onUpdate: () => {
                    instancedMesh.setColorAt(i, voxels[i].color);
                }
            })
        } else {
            targetPos = voxelsPerModel[newModelIdx][Math.floor(voxelsPerModel[newModelIdx].length * Math.random())].position;
        }

        // move to new position if it's determined
        gsap.to(voxels[i].position, {
            delay: .2 * Math.random(),
            duration: duration,
            x: targetPos.x,
            y: targetPos.y,
            z: targetPos.z,
            ease: "back.out(3)",
            onUpdate: () => {
                dummy.position.copy(voxels[i].position);
                dummy.updateMatrix();
                instancedMesh.setMatrixAt(i, dummy.matrix);
            }
        });
    }

    // increase the model rotation during transition
    gsap.to(instancedMesh.rotation, {
        duration: 1.2,
        y: "+=" + 1.3 * Math.PI,
        ease: "power2.out"
    })

    // show the right number of voxels
    gsap.to(instancedMesh, {
        duration: .4,
        count: voxelsPerModel[newModelIdx].length
    })

    // update the instanced mesh accordingly to voxels data
    // (no need to call it per each voxel)
    gsap.to({}, {
        duration: 1.5, // max transition duration
        onUpdate: () => {
            instancedMesh.instanceColor.needsUpdate = true;
            instancedMesh.instanceMatrix.needsUpdate = true;
        }
    });
}

其余的是关于用户界面。 我们注册预览上的点击以动画化到选定的模型中。 通过单击屏幕上的其他位置,我们将体素动画化到下一个模型。 为了管理点击,我们使用 mouseup/mousedown 事件和鼠标保持超时的组合,而不是点击事件。 它有助于避免模型选择和轨道控制事件之间的冲突。 你可以在 repo 中找到完整的代码。


原文链接:Turning 3D Models to Voxel Art with Three.js

BimAnt翻译整理,转载请标明出处