diff --git a/components/AssetPage/WebGL/GLTFViewer.module.scss b/components/AssetPage/WebGL/GLTFViewer.module.scss index 32aa422d..830f3ee8 100644 --- a/components/AssetPage/WebGL/GLTFViewer.module.scss +++ b/components/AssetPage/WebGL/GLTFViewer.module.scss @@ -15,3 +15,33 @@ right: 0.8em; bottom: 0.5em; } + +.labeledButton { + position: relative; + display: flex; + align-items: center; + + &:hover .mapLabel { + opacity: 1; + transform: translateX(0); + pointer-events: none; + } +} + +.mapLabel { + position: absolute; + right: 100%; + padding: 0.5em 1.4em 0.6em 1.1em; + margin-right: -0.4em; + white-space: nowrap; + font-size: 0.85em; + color: $c-text-light; + background: $c-background-dark; + border: 1px solid rgba($c-text-light, 0.1); + border-right: none; + border-radius: 2em 0 0 2em; + opacity: 0; + transform: translateX(0.5em); + transition: opacity 0.15s ease, transform 0.15s ease; + pointer-events: none; +} diff --git a/components/AssetPage/WebGL/GLTFViewer.tsx b/components/AssetPage/WebGL/GLTFViewer.tsx index f103dfbf..cc95aca9 100644 --- a/components/AssetPage/WebGL/GLTFViewer.tsx +++ b/components/AssetPage/WebGL/GLTFViewer.tsx @@ -1,7 +1,7 @@ import React, { FC, useReducer, useState, useEffect, useMemo, Suspense } from 'react' -import { Vector3, Quaternion, Box3, CineonToneMapping, Texture } from 'three' -import { Canvas } from '@react-three/fiber' -import { Environment, OrbitControls } from '@react-three/drei' +import { Vector3, Quaternion, Box3, CineonToneMapping, Texture, type Texture as ThreeTexture } from 'three' +import { Canvas, useFrame } from '@react-three/fiber' +import { Environment, OrbitControls, MeshTransmissionMaterial, useFBO } from '@react-three/drei' import { FullScreen, useFullScreenHandle } from 'react-full-screen' import { MdPublic, MdLayers, MdLanguage, MdNotInterested, MdFullscreen } from 'react-icons/md' @@ -26,64 +26,152 @@ interface Props { const linearMaps = ['normalMap', 'emissiveMap', 'metalnessMap', 'roughnessMap', 'aoMap'] -const renderMesh = (mesh, soloMap, wireframe, maxAnisotropy, transparent) => { - const objects = [] - if (mesh.children) { - mesh.children.forEach((child) => { - objects.push(renderMesh(child, soloMap, wireframe, maxAnisotropy, transparent)) - }) +const channelVertexShader = ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } - objects.push( - - - {soloMap === '' ? ( - - {Object.entries(mesh.material).map(([key, value]) => - value instanceof Texture ? ( - { + if (node.type === 'Mesh' && node.material) result.push(node) + if (node.children) node.children.forEach((c) => collectMeshes(c, result)) + return result +} + +const SceneMeshes: FC = ({ gltf, soloMap, wireframe, maxAnisotropy, transparent, transmissionBuffer }) => { + const meshes = useMemo(() => collectMeshes(gltf.scene), [gltf]) + + return ( + <> + {meshes.map((mesh) => { + const isArmChannel = soloMap === 'AO' || soloMap === 'ROUGHNESS' || soloMap === 'METALNESS' + const isTransmissive = mesh.material.transmission > 0 + const needsTransparent = transparent || mesh.material.transparent || isTransmissive + + return ( + + + {soloMap === '' ? ( + isTransmissive ? ( + + {Object.entries(mesh.material).map(([key, value]) => + value instanceof Texture ? ( + + ) : null + )} + + ) : ( + + {Object.entries(mesh.material).map(([key, value]) => + value instanceof Texture ? ( + + ) : null + )} + + ) + ) : isArmChannel ? ( + - ) : null - )} - - ) : ( - - {mesh.material['map'] && soloMap === 'DIFFUSE' && ( - - )} - {mesh.material['normalMap'] && soloMap === 'NORMAL' && ( - - )} - {mesh.material['metalnessMap'] && soloMap === 'METALNESS' && ( - - )} - {mesh.material['roughnessMap'] && soloMap === 'ROUGHNESS' && ( - - )} - - )} - - - - - - + ) : ( + + {mesh.material['map'] && soloMap === 'DIFFUSE' && ( + + )} + {mesh.material['normalMap'] && soloMap === 'NORMAL' && ( + + )} + + )} + + + + + + + ) + })} + ) - return objects +} + +interface SceneProps extends Omit {} + +const Scene: FC = (props) => { + const buffer = useFBO(256, 256) + + useFrame((state) => { + state.gl.setRenderTarget(buffer) + state.gl.render(state.scene, state.camera) + state.gl.setRenderTarget(null) + }) + + return } const GLTFViewer: FC = ({ show, assetID, files, onLoad }) => { @@ -91,10 +179,10 @@ const GLTFViewer: FC = ({ show, assetID, files, onLoad }) => { const handle = useFullScreenHandle() - const [soloMap, setSoloMap] = useState<'NORMAL' | 'METALNESS' | 'ROUGHNESS' | 'DIFFUSE' | ''>('') + const [soloMap, setSoloMap] = useState<'NORMAL' | 'AO' | 'ROUGHNESS' | 'METALNESS' | 'DIFFUSE' | ''>('') const [wireframe, setWireframe] = useState(false) - const [showEnvironment, setShowEnvironment] = useState(true) + const [showEnvironment, setShowEnvironment] = useState(false) const [envPreset, setEnvPreset] = useState< 'warehouse' | 'sunset' | 'dawn' | 'night' | 'forest' | 'apartment' | 'studio' | 'city' | 'park' | 'lobby' >('warehouse') @@ -129,11 +217,15 @@ const GLTFViewer: FC = ({ show, assetID, files, onLoad }) => { return
No preview available for this model, sorry :(
} - const gltfFiles = files.gltf['4k']?.gltf ?? files.gltf['2k'].gltf + const gltfFiles = { ...files.gltf } if (files['Alpha'] && files['Diffuse']) { gltfFiles['alpha'] = files['Diffuse']['4k']?.png ?? files['Diffuse']['2k'].png } - const processedGLTFFromAPI = useGLTFFromAPI(gltfFiles) + const { gltf: processedGLTFFromAPI, texturesLoaded } = useGLTFFromAPI(gltfFiles) + + useEffect(() => { + if (texturesLoaded) onLoad() + }, [texturesLoaded]) // As this functionality is not really necessary, it is overkill to useReducer to handle it. // If we need finer grained interaction per part it is useful. @@ -170,6 +262,7 @@ const GLTFViewer: FC = ({ show, assetID, files, onLoad }) => {
= ({ show, assetID, files, onLoad }) => { onCreated={({ gl }) => { gl.toneMapping = CineonToneMapping setMaxAnisotropy(Math.max(1, Math.min(16, gl.capabilities.getMaxAnisotropy()))) - onLoad() }} > + {!texturesLoaded && ( + <> + + + + )} - {processedGLTFFromAPI && - state.meshes && - Object.values(state.meshes).map((node) => - renderMesh(node.mesh, soloMap, wireframe, maxAnisotropy, !!gltfFiles['alpha']) - )} + {processedGLTFFromAPI && ( + + )}
@@ -231,27 +333,56 @@ const GLTFViewer: FC = ({ show, assetID, files, onLoad }) => { }} /> }> - } - active={soloMap === 'DIFFUSE'} - onClick={() => { - setSoloMap('DIFFUSE') - }} - /> - } - active={soloMap === 'NORMAL'} - onClick={() => { - setSoloMap('NORMAL') - }} - /> - } - active={soloMap === 'METALNESS'} - onClick={() => { - setSoloMap('METALNESS') - }} - /> +
+ Base Color + } + active={soloMap === 'DIFFUSE'} + onClick={() => { + setSoloMap('DIFFUSE') + }} + /> +
+
+ Normal + } + active={soloMap === 'NORMAL'} + onClick={() => { + setSoloMap('NORMAL') + }} + /> +
+
+ Ambient Occlusion + } + active={soloMap === 'AO'} + onClick={() => { + setSoloMap('AO') + }} + /> +
+
+ Roughness + } + active={soloMap === 'ROUGHNESS'} + onClick={() => { + setSoloMap('ROUGHNESS') + }} + /> +
+
+ Metalness + } + active={soloMap === 'METALNESS'} + onClick={() => { + setSoloMap('METALNESS') + }} + /> +
} active={soloMap === ''} diff --git a/components/AssetPage/WebGL/useGLTFFromAPI.ts b/components/AssetPage/WebGL/useGLTFFromAPI.ts index cd77075d..c2526096 100644 --- a/components/AssetPage/WebGL/useGLTFFromAPI.ts +++ b/components/AssetPage/WebGL/useGLTFFromAPI.ts @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react' import { GLTF, GLTFLoader } from 'three-stdlib' +import { MeshStandardMaterial } from 'three' interface APIDataItem { readonly url: string @@ -7,52 +8,125 @@ interface APIDataItem { readonly md5: string } -interface GLTFFromAPI extends APIDataItem { +interface GLTFResolution extends APIDataItem { readonly include: { [s: string]: APIDataItem } - readonly alpha: APIDataItem } -export const useGLTFFromAPI = (APIModelObject: GLTFFromAPI): GLTF => { - const [mappedGLTF, setMappedGLTF] = useState() - const [processedGLTF, setProcessedGLTF] = useState() +export interface GLTFFiles { + readonly '1k'?: { gltf: GLTFResolution } + readonly '2k'?: { gltf: GLTFResolution } + readonly '4k'?: { gltf: GLTFResolution } + alpha?: APIDataItem +} + +// 1x1 transparent PNG so GLTFLoader skips texture fetches in the first pass. +const STUB_PNG = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' + +const buildMappedGLTF = (data: any, res: GLTFResolution, alpha: APIDataItem | undefined, stubTextures: boolean) => ({ + ...data, + images: data.images.map((img: { uri: string }) => { + if (alpha && (img.uri.endsWith('_diff_4k.jpg') || img.uri.endsWith('_diff_2k.jpg') || img.uri.endsWith('_diff_1k.jpg'))) { + return stubTextures ? { ...img, mimeType: 'image/png', uri: STUB_PNG } : { ...img, mimeType: 'image/png', uri: alpha.url } + } + return stubTextures + ? { ...img, mimeType: 'image/png', uri: STUB_PNG } + : { ...img, uri: res.include[img.uri].url } + }), + buffers: data.buffers.map((buf: { uri: string }) => ({ + byteLength: res.include[buf.uri].size, + uri: res.include[buf.uri].url, + })), + // When stubbing, force matte white so the model doesn't look fully metallic. + materials: stubTextures + ? data.materials?.map((mat: any) => ({ + ...mat, + pbrMetallicRoughness: { + ...mat.pbrMetallicRoughness, + metallicFactor: 0, + roughnessFactor: 1, + metallicRoughnessTexture: undefined, + }, + normalTexture: undefined, + occlusionTexture: undefined, + })) + : data.materials, +}) + +const applyAOMaps = (gltf: GLTF) => { + gltf.scene.traverse((obj: any) => { + if (!obj.isMesh) return + const mat = obj.material as MeshStandardMaterial + if (mat && !mat.aoMap && mat.roughnessMap) { + mat.aoMap = mat.roughnessMap + mat.aoMap.channel = 0 // red channel of ARM texture + mat.aoMapIntensity = 1 + mat.needsUpdate = true + } + }) +} + +export interface ProgressiveGLTF { + gltf: GLTF | undefined + texturesLoaded: boolean +} + +export const useGLTFFromAPI = (files: GLTFFiles): ProgressiveGLTF => { + const [gltf, setGltf] = useState() + const [texturesLoaded, setTexturesLoaded] = useState(false) + + const initialRes = (files['1k'] ?? files['2k'] ?? files['4k'])! + const finalRes = (files['4k'] ?? files['2k'] ?? files['1k'])! useEffect(() => { - fetch(APIModelObject.url) + let cancelled = false + + fetch(initialRes.gltf.url) .then((res) => res.json()) .then((data) => { - setMappedGLTF({ - ...data, - images: data.images.map((key, idx) => { - if (APIModelObject.alpha && (key.uri.endsWith('_diff_4k.jpg') || key.uri.endsWith('_diff_2k.jpg'))) { - return { - ...data.images[idx], - mimeType: 'image/png', - uri: APIModelObject.alpha.url, - } - } - return { - ...data.images[idx], - uri: APIModelObject.include[key.uri].url, + if (cancelled) return + + // Phase 1 + const loader = new GLTFLoader() + loader.parse( + JSON.stringify(buildMappedGLTF(data, initialRes.gltf, files.alpha, true)), + 'https://dl.polyhaven.com/', + (parsed) => { + if (cancelled) return + setGltf(parsed) + + // Phase 2: re-parse with real textures and swap the scene. + const loadFull = (fullData: any) => { + if (cancelled) return + const fullLoader = new GLTFLoader() + fullLoader.parse( + JSON.stringify(buildMappedGLTF(fullData, finalRes.gltf, files.alpha, false)), + 'https://dl.polyhaven.com/', + (fullParsed) => { + if (!cancelled) { + applyAOMaps(fullParsed) + setGltf(fullParsed) + setTexturesLoaded(true) + } + } + ) } - }), - buffers: data.buffers.map((key) => { - return { - byteLength: APIModelObject.include[key.uri].size, - uri: APIModelObject.include[key.uri].url, + + if (finalRes.gltf.url === initialRes.gltf.url) { + loadFull(data) + } else { + fetch(finalRes.gltf.url) + .then((r) => r.json()) + .then(loadFull) } - }), - }) + } + ) }) - }, []) - - useEffect(() => { - if (!mappedGLTF) return - const _GLTFLoader = new GLTFLoader() - // should not hard code baseUrl here, get from API - // can also use deeper nesting but most importantly it is just needed to make glTF URLs relative - _GLTFLoader.parse(JSON.stringify(mappedGLTF), 'https://dl.polyhaven.com/', (data) => setProcessedGLTF(data)) - }, [mappedGLTF]) + return () => { + cancelled = true + } + }, [initialRes.gltf.url]) - return processedGLTF + return { gltf, texturesLoaded } } diff --git a/tsconfig.json b/tsconfig.json index 6a973ac5..f5c490df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", - "incremental": true + "incremental": true, + "ignoreDeprecations": "6.0" }, "include": [ "next-env.d.ts",