fix: apply opacity maps in preview

This commit is contained in:
Tom Boullay
2026-04-27 17:19:21 +02:00
parent 2a7be1dd52
commit 473fa0f6e1
2 changed files with 92 additions and 17 deletions
+1
View File
@@ -69,6 +69,7 @@ Access the app at `http://localhost:3000`
>
> Local 3D preview supports `model.gltf` folders by resolving dropped companion files such as `model.bin` and textures through local object URLs.
> The preview also shows a small model stats helper with estimated draw calls, meshes, triangles, materials, and texture count.
> Opacity helper textures can be named `opacity.png` for the whole model or `opacity_part-name.png` to target a mesh/material whose name contains `part-name`; alpha-channel PNGs are converted to alpha maps for the preview.
### Production (Coolify / Docker)
+91 -17
View File
@@ -4,8 +4,8 @@ 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, Object3D, Texture } from 'three'
import { TextureLoader } from 'three'
import { CanvasTexture, Mesh, TextureLoader } from 'three'
import type { Material, Object3D, Texture } from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
export interface ModelStats {
@@ -21,6 +21,14 @@ interface OpacityMapEntry {
url: string
}
interface AlphaMapMaterial extends Material {
alphaMap: Texture | null
transparent: boolean
alphaTest: number
}
const alphaMapTextureCache = new WeakMap<Texture, Texture>()
function resolveAssetUrl(requestedUrl: string, assetUrls: Record<string, string>) {
if (requestedUrl.startsWith('blob:') || requestedUrl.startsWith('data:')) {
return requestedUrl
@@ -43,19 +51,84 @@ function getOpacityMapEntries(assetUrls: Record<string, string>) {
}, [])
}
function normalizeMatchName(name: string) {
return name
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]/g, '')
}
function getMaterialName(material: Material) {
return material.name.toLowerCase()
return normalizeMatchName(material.name)
}
function getObjectName(mesh: Mesh) {
return mesh.name.toLowerCase()
return normalizeMatchName(mesh.name)
}
function isMesh(object: Object3D): object is Mesh {
return object instanceof Mesh
}
function supportsAlphaMap(material: Material): material is AlphaMapMaterial {
return 'alphaMap' in material
}
function createAlphaMapTexture(texture: Texture) {
const cachedTexture = alphaMapTextureCache.get(texture)
if (cachedTexture) return cachedTexture
const image = texture.image as unknown
const isImageBitmap = typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap
if (!(image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || isImageBitmap)) {
texture.flipY = false
alphaMapTextureCache.set(texture, texture)
return texture
}
const canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const context = canvas.getContext('2d')
if (!context) {
texture.flipY = false
alphaMapTextureCache.set(texture, texture)
return texture
}
context.drawImage(image, 0, 0)
const imageData = context.getImageData(0, 0, canvas.width, canvas.height)
const pixels = imageData.data
for (let i = 0; i < pixels.length; i += 4) {
const alpha = pixels[i + 3]
const luminance = Math.round((pixels[i] * 0.2126) + (pixels[i + 1] * 0.7152) + (pixels[i + 2] * 0.0722))
const opacity = alpha < 255 ? alpha : luminance
pixels[i] = opacity
pixels[i + 1] = opacity
pixels[i + 2] = opacity
pixels[i + 3] = 255
}
context.putImageData(imageData, 0, 0)
const alphaTexture = new CanvasTexture(canvas)
alphaTexture.flipY = false
alphaTexture.needsUpdate = true
alphaMapTextureCache.set(texture, alphaTexture)
return alphaTexture
}
function applyAlphaMap(material: Material, texture: Texture) {
if (!('alphaMap' in material)) return
if (!supportsAlphaMap(material)) return
texture.flipY = false
material.alphaMap = texture
material.alphaMap = createAlphaMapTexture(texture)
material.transparent = true
material.alphaTest = 0.01
material.needsUpdate = true
@@ -91,14 +164,13 @@ function getModelStats(scene: Object3D, assetUrls: Record<string, string>): Mode
let triangles = 0
scene.traverse((object) => {
const mesh = object as Mesh
if (!mesh.isMesh || !mesh.geometry || !mesh.material) return
if (!isMesh(object) || !object.geometry || !object.material) return
meshes += 1
triangles += countGeometryTriangles(mesh)
drawCalls += estimateMeshDrawCalls(mesh)
triangles += countGeometryTriangles(object)
drawCalls += estimateMeshDrawCalls(object)
const meshMaterials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const meshMaterials = Array.isArray(object.material) ? object.material : [object.material]
meshMaterials.forEach((material) => materials.add(material))
})
@@ -120,7 +192,10 @@ function pickOpacityMap(
const objectName = getObjectName(mesh)
const materialName = getMaterialName(material)
const targetedIndex = entries.findIndex((entry) => (
entry.target && (objectName.includes(entry.target) || materialName.includes(entry.target))
entry.target && (
objectName.includes(normalizeMatchName(entry.target))
|| materialName.includes(normalizeMatchName(entry.target))
)
))
if (targetedIndex >= 0) return textures[targetedIndex]
@@ -154,12 +229,11 @@ function Model({
if (opacityMapEntries.length === 0) return
scene.traverse((object) => {
const mesh = object as Mesh
if (!mesh.isMesh || !mesh.material) return
if (!isMesh(object) || !object.material) return
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const materials = Array.isArray(object.material) ? object.material : [object.material]
materials.forEach((material) => {
const opacityMap = pickOpacityMap(mesh, material, opacityMapEntries, opacityMaps)
const opacityMap = pickOpacityMap(object, material, opacityMapEntries, opacityMaps)
if (opacityMap) applyAlphaMap(material, opacityMap)
})
})