From d9c47c16eb21d88cb2e04fc09f62692255c1bf1f Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 27 Apr 2026 17:04:38 +0200 Subject: [PATCH] feat: add model stats helper to viewer --- components/ModelViewer.tsx | 34 ++++++++++++++- components/SceneViewer.tsx | 86 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/components/ModelViewer.tsx b/components/ModelViewer.tsx index 4d91237..4dcb057 100644 --- a/components/ModelViewer.tsx +++ b/components/ModelViewer.tsx @@ -1,6 +1,7 @@ 'use client' import { useEffect, useState } from 'react' +import type { ModelStats } from './SceneViewer' interface ModelViewerProps { url: string @@ -11,7 +12,12 @@ interface ModelViewerProps { export default function ModelViewer({ url, assetUrls, filename, size }: ModelViewerProps) { const canPreview = filename.toLowerCase().endsWith('.gltf') - const [Scene, setScene] = useState }> | null>(null) + const [stats, setStats] = useState(null) + const [Scene, setScene] = useState + onStatsReady: (stats: ModelStats) => void + }> | null>(null) useEffect(() => { if (!canPreview) return @@ -53,7 +59,31 @@ export default function ModelViewer({ url, assetUrls, filename, size }: ModelVie {size} - + {stats && ( +
+ + Draw calls + {stats.drawCalls} + + + Meshes + {stats.meshes} + + + Triangles + {stats.triangles.toLocaleString('fr-FR')} + + + Materials + {stats.materials} + + + Textures + {stats.textures} + +
+ )} + ) } diff --git a/components/SceneViewer.tsx b/components/SceneViewer.tsx index 2a9adba..97c7d73 100644 --- a/components/SceneViewer.tsx +++ b/components/SceneViewer.tsx @@ -4,10 +4,18 @@ import { Suspense, useEffect } from 'react' import { Canvas } from '@react-three/fiber' import { Stage, OrbitControls } from '@react-three/drei' import { useLoader } from '@react-three/fiber' -import type { Material, Mesh, Texture } from 'three' +import type { Material, Mesh, Object3D, Texture } from 'three' import { TextureLoader } from 'three' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' +export interface ModelStats { + drawCalls: number + materials: number + meshes: number + textures: number + triangles: number +} + interface OpacityMapEntry { target: string url: string @@ -53,6 +61,56 @@ function applyAlphaMap(material: Material, texture: Texture) { material.needsUpdate = true } +function countGeometryTriangles(mesh: Mesh) { + const geometry = mesh.geometry + + if (geometry.index) return Math.floor(geometry.index.count / 3) + + const position = geometry.getAttribute('position') + return position ? Math.floor(position.count / 3) : 0 +} + +function getMaterialCount(material: Material | Material[]) { + return Array.isArray(material) ? material.length : 1 +} + +function estimateMeshDrawCalls(mesh: Mesh) { + if (mesh.geometry.groups.length > 0) return mesh.geometry.groups.length + + return getMaterialCount(mesh.material) +} + +function getTextureCount(assetUrls: Record) { + return Object.keys(assetUrls).filter((filename) => /\.(png|jpe?g|webp)$/i.test(filename)).length +} + +function getModelStats(scene: Object3D, assetUrls: Record): ModelStats { + const materials = new Set() + let drawCalls = 0 + let meshes = 0 + let triangles = 0 + + scene.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || !mesh.geometry || !mesh.material) return + + meshes += 1 + triangles += countGeometryTriangles(mesh) + drawCalls += estimateMeshDrawCalls(mesh) + + const meshMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + meshMaterials.forEach((material) => materials.add(material)) + }) + + return { + drawCalls, + materials: materials.size, + meshes, + textures: getTextureCount(assetUrls), + triangles, + } +} + function pickOpacityMap( mesh: Mesh, material: Material, @@ -73,13 +131,25 @@ function pickOpacityMap( return entries.length === 1 ? textures[0] : undefined } -function Model({ url, assetUrls }: { url: string; assetUrls: Record }) { +function Model({ + url, + assetUrls, + onStatsReady, +}: { + url: string + assetUrls: Record + onStatsReady: (stats: ModelStats) => void +}) { const { scene } = useLoader(GLTFLoader, url, (loader) => { loader.manager.setURLModifier((requestedUrl) => resolveAssetUrl(requestedUrl, assetUrls)) }) const opacityMapEntries = getOpacityMapEntries(assetUrls) const opacityMaps = useLoader(TextureLoader, opacityMapEntries.map((entry) => entry.url)) as Texture[] + useEffect(() => { + onStatsReady(getModelStats(scene, assetUrls)) + }, [assetUrls, onStatsReady, scene]) + useEffect(() => { if (opacityMapEntries.length === 0) return @@ -98,12 +168,20 @@ function Model({ url, assetUrls }: { url: string; assetUrls: Record } -export default function SceneViewer({ url, assetUrls }: { url: string; assetUrls: Record }) { +export default function SceneViewer({ + url, + assetUrls, + onStatsReady, +}: { + url: string + assetUrls: Record + onStatsReady: (stats: ModelStats) => void +}) { return ( - +