Automatic low-polygon reduction for GLB models

I’m trying to make GLTF low polygon using R3F. So I created the following code, but when I run it, the geometry is integrated, so the material cannot be applied correctly. Do you have any suggestions?

I used this guy’s code as a reference.

Using 3dModel(schech fab):Human Head - Download Free 3D model by BirdChen (@bird219) [926ba74] - Sketchfab

import { 
    Mesh, 
    Object3D, 
    BufferGeometry, 
    Material, 
    DoubleSide, 
    BufferAttribute, 
    Box3, 
    MeshStandardMaterial
} from "three";

import { GLTFLoader, GLTF } from "three/examples/jsm/loaders/GLTFLoader";
import { SimplifyModifier } from "three/examples/jsm/modifiers/SimplifyModifier";
import { generateUUID } from "three/src/math/MathUtils";

export interface IGLTFLoaderProps {
    filePath     : string;            // ファイルパス
    height?      : number;            // 変更したい高さ
    simModRatio? : number;            // ポリゴンの削減率(0 ~ 1) デフォルト0.5
    shadows?     : boolean;           // 影をつけるか
    isWireFrame? : boolean;           // ワイヤーフレームにするか
    maxIteration?: number;
    onCallback?  : (e?: any) => void; 
}

// 親コンポーネントに返す値
export interface IGLTFLoadData{
    gltf        : GLTF;
    simModObj   : Object3D;
}

interface IIterativeModParam {
    decimationFaceCount  : number; // 削減したい三角形の数
    geometry             : BufferGeometry; // ジオメトリ
    updateCallback?      : (geometry: BufferGeometry) => void;
}

export const MyGltfLoader = async (props: IGLTFLoaderProps): Promise<IGLTFLoadData> => {

    /**
     * 初期値
     */
    var myMesh = new Mesh();
    const material = new MeshStandardMaterial({wireframe: true, color: 0xff0000});
    material.flatShading = true
    material.side = DoubleSide;
    const modifier = new SimplifyModifier();
    const MAX_FACE_COUNT_PER_ITERATION = props.maxIteration? props.maxIteration: 2500; // 1度に処理する最大削減数

    /**
     * ベース:https://github.com/AndrewSink/3D-Low-Poly-Generator/tree/main
     * @param params 
     */
    const iterativeModifier = (params: IIterativeModParam): BufferGeometry => {
        let modifierInProgress = true;
        let modifierProgressPercentage = 0;
        // 三角面数のカウント
        let startingFaceCount = params.geometry.attributes.position.count;
        // 現在の三角面数
        let currentFaceCount = startingFaceCount;
        // 変更後の三角面数
        let targetFaceCount = startingFaceCount - params.decimationFaceCount;
        let totalFacesToDecimate = startingFaceCount - targetFaceCount;
        let remainingFacesToDecimate = currentFaceCount - targetFaceCount;
    
        let iterationFaceCount = currentFaceCount - MAX_FACE_COUNT_PER_ITERATION;
    
        let simplifiedGeometry = params.geometry.clone();
        while (iterationFaceCount > targetFaceCount) {
            simplifiedGeometry = modifier.modify(simplifiedGeometry, MAX_FACE_COUNT_PER_ITERATION);
            if (params.updateCallback) params.updateCallback(simplifiedGeometry);
            currentFaceCount = simplifiedGeometry.attributes.position.count;
            iterationFaceCount = currentFaceCount - MAX_FACE_COUNT_PER_ITERATION;
            remainingFacesToDecimate = currentFaceCount - targetFaceCount;
            modifierProgressPercentage = Math.floor(((totalFacesToDecimate - remainingFacesToDecimate) / totalFacesToDecimate) * 100);
        }

        try {
            let tmpGeo = simplifiedGeometry.clone();
            tmpGeo = modifier.modify(tmpGeo, currentFaceCount - targetFaceCount);
            if (tmpGeo.drawRange.count === Infinity){
                console.log("(Three.js) No Next Vertex Error: \n頂点検出エラーのため飛ばします");
            }
            else simplifiedGeometry = tmpGeo
        }
        catch(e){}
        
        if (params.updateCallback) params.updateCallback(simplifiedGeometry);
        modifierProgressPercentage = 100;
        modifierInProgress = false;

        return simplifiedGeometry;
    }

    /**
     * ジオメトリの統合
     * @param geometry1 
     * @param geometry2 
     * @returns 
     */
    const mergeBufferGeometry = (geometry1: BufferGeometry, geometry2: BufferGeometry): BufferGeometry => {
        // 頂点属性のオフセット
        var offset = geometry1.attributes.position.count;
        
        // 頂点属性を結合する
        var positions1 = geometry1.attributes.position.array;
        var positions2 = geometry2.attributes.position.array;
        var mergedPositions = new Float32Array(positions1.length + positions2.length);
        mergedPositions.set(positions1, 0);
        mergedPositions.set(positions2, positions1.length);
        
        // 法線を結合する
        var normals1 = geometry1.attributes.normal.array;
        var normals2 = geometry2.attributes.normal.array;
        var mergedNormals = new Float32Array(normals1.length + normals2.length);
        mergedNormals.set(normals1, 0);
        mergedNormals.set(normals2, normals1.length);
        
        // UVを結合する
        var uvs1 = geometry1.attributes.uv.array;
        var uvs2 = geometry2.attributes.uv.array;
        var mergedUVs = new Float32Array(uvs1.length + uvs2.length);
        mergedUVs.set(uvs1, 0);
        mergedUVs.set(uvs2, uvs1.length);
        
        // マージ済みの頂点属性を新しいバッファジオメトリに設定する
        var mergedGeometry = new BufferGeometry();
        mergedGeometry.setAttribute('position', new BufferAttribute(mergedPositions, 3));
        mergedGeometry.setAttribute('normal', new BufferAttribute(mergedNormals, 3));
        mergedGeometry.setAttribute('uv', new BufferAttribute(mergedUVs, 2));
        
        // インデックスを結合する
        var indices1 = geometry1.index.array;
        var indices2 = geometry2.index.array;
        var mergedIndices = new (indices1.length > 65535 ? Uint32Array : Uint16Array)(indices1.length + indices2.length);
        mergedIndices.set(indices1, 0);
        for (var i = 0; i < indices2.length; i++) {
            mergedIndices[indices1.length + i] = indices2[i] + offset;
        }
        
        // マージ済みのインデックスを新しいバッファジオメトリに設定する
        mergedGeometry.setIndex(new BufferAttribute(mergedIndices, 1));
        
        return mergedGeometry;
    }

    
    return new Promise((resolve) => {

        
        const loader = new GLTFLoader();
        loader.load(
            props.filePath,
            async (gltf: GLTF) => {
                // ジオメトリの取得処理
                let geometry: BufferGeometry;
                let mat     : Material[] = [];
                console.log("GLTFモデルの中身を確認");
                console.log(gltf.scene);
                gltf.scene.traverse((node: Object3D | Mesh) => {
                    if ((node as Mesh).isMesh && node instanceof Mesh){
                        const mesh: Mesh = node.clone();
                        if (props.isWireFrame) node.material = material;// 強制敵にWireFrameに変換
                        else {
                            if (node.material){
                                if (node.material instanceof Material){
                                    mat.push(node.material.clone())
                                }
                                else if (node.material instanceof Array<Material>){
                                    node.material.map(m => mat.push(m.clone()));
                                }
                            }
                        };
                        if (!geometry){
                            geometry = mesh.geometry.clone();
                            geometry.uuid = generateUUID(); //別のUUIDとして生成
                        }
                        else {
                            geometry = mergeBufferGeometry(geometry, mesh.geometry.clone());
                        }
                        node.castShadow = props.shadows? true :false;
                        node.receiveShadow = props.shadows? true :false;
                    }
                });
                let bbox = new Box3().setFromObject(gltf.scene.clone());
                let baseHeight = bbox.max.y - bbox.min.y;
                if (props.height) {
                    // 高さが入力されていれば、その高さに合うようにリサイズする
                    const nh = baseHeight;
                    const ns = props.height / nh;
                    console.log("デフォルトサイズ: ", nh, "スケールサイズ: ", ns);
                    gltf.scene.scale.multiplyScalar(ns);
                    bbox = new Box3().setFromObject(gltf.scene.clone());
                    baseHeight = bbox.max.y - bbox.min.y;
                    console.log("リサイズ後の高さサイズ: ", baseHeight)
                }
                
                // 空のMeshにセットする
                if (props.isWireFrame){
                    myMesh.material = material;
                } 
                else {
                    // 元のマテリアルデータを適応させる
                    myMesh.material = mat;
                    // ※ジオメトリを統合しているので、正しいマテリアルを付与できない。どうすればいいか。
                    
                }
                myMesh.geometry = geometry;
                var tempGeometry = new Mesh();
                tempGeometry.geometry = geometry;
                geometry.computeVertexNormals();
                myMesh.geometry.center();
                myMesh.rotation.x = 90 * Math.PI / 180;
                myMesh.geometry.computeBoundingBox();
                tempGeometry.position.copy(myMesh.position);

                tempGeometry.geometry = modifier.modify(geometry, 0);
                myMesh.geometry = modifier.modify(geometry, 0);
                console.log('変換前:頂点数:', ((myMesh.geometry.attributes.position.count * 6) - 12));
                console.log('変換前:三角数:', ((myMesh.geometry.attributes.position.count * 6) - 12) / 3);

                const simModRate = props.simModRatio? props.simModRatio: 0.5;
                const count = Math.floor(myMesh.geometry.attributes.position.count * simModRate);
                console.log("削減ポリゴン数: ", count);
                const newGeometory = iterativeModifier({
                    decimationFaceCount: myMesh.geometry.attributes.position.count * simModRate, 
                    geometry: myMesh.geometry
                });
                myMesh.geometry = newGeometory;
                console.log('変換後:頂点数:', ((newGeometory.attributes.position.count * 6) - 12));
                console.log('変換後:三角数:', ((newGeometory.attributes.position.count * 6) - 12) / 3);

                const conbox = new Box3().setFromObject(myMesh);
                console.log("conbox", conbox);
                const conHeight = conbox.max.y - conbox.min.y;
                console.log("[高さ差分確認] ポリゴン削減前モデルの高さ: ", baseHeight, " ポリゴン削減後モデルの高さ:", conHeight);

                // 高さを合わせる
                myMesh.scale.multiplyScalar(baseHeight/conHeight);
                myMesh.position.y = ((bbox.max.y - bbox.min.y) / 2);

                // SimpiferModifierで自動LODを実施
                let simModObj = new Object3D();
                simModObj.add(myMesh);

                console.log("正常にモデルのロードが完了しました。");
                
                return resolve(
                    {
                        gltf: gltf,
                        simModObj: simModObj
                    }
                )
            },
            (xhr: any) => {
                // ロード率を計算してCallbackで返す 後日記述
            },
            (err: any) => {
                console.error("3Dモデルロード中にエラーが出ました");
                throw "[モデルロードエラー]モデルのパスや設定を確認してください。";
            }
        );
    });
}


drawing component

import { useEffect, useState } from "react";
import { OrbitControls, Environment } from "@react-three/drei";
import { MyGltfLoader } from "./MyGLTFLoader";
import { GLTF } from "three/examples/jsm/loaders/GLTFLoader";

export const TestGLTFComponent = () => {
    const [gltf, setGLTF] = useState<GLTF>();
    const [lowObj, setLowObj] = useState<Object3D>();
    useEffect(() => {
        (async () => {
            const filePath = "human_head.glb";
            const loadGlbModel = await MyGltfLoader({ 
                filePath: filePath, 
                height: 1.5, 
                simModRatio: 0.9,
                shadows: true,
                isWireFrame: true
            });
            setGLTF(loadGlbModel.gltf);
            setLowObj(loadGlbModel.simModObj);
        })()
    }, [])
    return (
        <>
            {gltf &&
                <mesh position={[-1, -1, 0]}>
                    <primitive object={gltf.scene} />
                </mesh>
            }
            {lowObj &&
                <mesh position={[1, -1, 0]}>
                    <primitive object={lowObj} />
                </mesh>
            }
            <OrbitControls/>
            <Environment preset="dawn" background blur={0.7} />
            <directionalLight position={[100, 100, 100]} intensity={0.8} castShadow />
        </>
    );
}

Case, You want to confirm the bug, isWireFrame Props is false.

SimplifyModifier is very limited, for one thing it does not support normals, UVs, or vertex colors. Many materials will not work without those.

If you have the option of doing simplification on the model before loading it in R3F, that is better and your whole application will load faster. If you need simplification to happen inside R3F we can probably offer better suggestions for that too, though.

1 Like

thank you for your reply.
I’m glad that donmccurdy, who has a deep knowledge of 3js, can comment.

So it is desirable to reduce the polygons of the model before loading it.

However, the system I’m trying to develop this time requires creating multiple simplified models from a single model.

If you know of a good OSS or reference repository that can be simplified before loading a single model without pre-cased GLB files, please let me know.

There was a CodeSandbox I found earlier that looked like it might work.
I haven’t parsed it yet, but is this valid?

The CodeSandbox above uses a very old version of three.js, so I think you’d need to read through the old/new SimplifyModifier very carefully and update the new one to support UVs. It may be a lot of work.

gltfpack is the easiest option I know of, if you are able to do simplification on the command line:

For something that runs in JavaScript (web or node.js), you could use glTF Transform. You’d load the model with WebIO instead of GLTFLoader, then clone and simplify it as many times as you need:

import { WebIO } from '@gltf-transform/core';
import { simplify, weld } from '@gltf-transform/functions';
import { MeshoptSimplifier } from 'meshoptimizer';

const io = new WebiO();
const document = await io.read('path/to/model.glb');
const document2 = document.clone();

// simplify a lot
await document.transform(
  weld({ tolerance: 0.01 }),
  simplify({ simplifier: MeshoptSimplifier, ratio: 0.01, error: 0.01 })
);

// simplify a little
await document2.transform(
  weld({ tolerance: 0.0001 }),
  simplify({ simplifier: MeshoptSimplifier, ratio: 0.01, error: 0.0001 })
);

Then use WebIO to write a GLB, download that or parse it with GLTFLoader or whatever you need.

1 Like

thank you very much.

OK,
hard to parse and add
Wait for the Simplifiy modifier update.

Oh so easy!
Thank you for teaching me. I’m going to try using it right away.