From 3244b70bbfb99f1f99244d3dba5726ded6ca25c1 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Mon, 27 Apr 2026 14:47:36 +0200 Subject: [PATCH] feat: support opacity maps in gltf preview --- components/SceneViewer.tsx | 75 +++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/components/SceneViewer.tsx b/components/SceneViewer.tsx index 8e898d3..2a9adba 100644 --- a/components/SceneViewer.tsx +++ b/components/SceneViewer.tsx @@ -1,11 +1,18 @@ 'use client' -import { Suspense } from 'react' +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 { TextureLoader } from 'three' import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js' +interface OpacityMapEntry { + target: string + url: string +} + function resolveAssetUrl(requestedUrl: string, assetUrls: Record) { if (requestedUrl.startsWith('blob:') || requestedUrl.startsWith('data:')) { return requestedUrl @@ -17,10 +24,76 @@ function resolveAssetUrl(requestedUrl: string, assetUrls: Record return filename ? assetUrls[filename] || requestedUrl : requestedUrl } +function getOpacityMapEntries(assetUrls: Record) { + return Object.entries(assetUrls).reduce((entries, [filename, url]) => { + const match = filename.toLowerCase().match(/^opacity(?:[_-](.+))?\.(png|jpe?g|webp)$/) + + if (!match) return entries + + entries.push({ target: match[1] || '', url }) + return entries + }, []) +} + +function getMaterialName(material: Material) { + return material.name.toLowerCase() +} + +function getObjectName(mesh: Mesh) { + return mesh.name.toLowerCase() +} + +function applyAlphaMap(material: Material, texture: Texture) { + if (!('alphaMap' in material)) return + + texture.flipY = false + material.alphaMap = texture + material.transparent = true + material.alphaTest = 0.01 + material.needsUpdate = true +} + +function pickOpacityMap( + mesh: Mesh, + material: Material, + entries: OpacityMapEntry[], + textures: Texture[], +) { + const objectName = getObjectName(mesh) + const materialName = getMaterialName(material) + const targetedIndex = entries.findIndex((entry) => ( + entry.target && (objectName.includes(entry.target) || materialName.includes(entry.target)) + )) + + if (targetedIndex >= 0) return textures[targetedIndex] + + const genericIndex = entries.findIndex((entry) => entry.target === '') + if (genericIndex >= 0) return textures[genericIndex] + + return entries.length === 1 ? textures[0] : undefined +} + function Model({ url, assetUrls }: { url: string; assetUrls: Record }) { 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(() => { + if (opacityMapEntries.length === 0) return + + scene.traverse((object) => { + const mesh = object as Mesh + if (!mesh.isMesh || !mesh.material) return + + const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material] + materials.forEach((material) => { + const opacityMap = pickOpacityMap(mesh, material, opacityMapEntries, opacityMaps) + if (opacityMap) applyAlphaMap(material, opacityMap) + }) + }) + }, [scene, opacityMapEntries, opacityMaps]) return }