diff --git a/examples/jsm/utils/InstancedVolume.js b/examples/jsm/utils/InstancedVolume.js new file mode 100644 index 00000000000000..a2c0b188eec001 --- /dev/null +++ b/examples/jsm/utils/InstancedVolume.js @@ -0,0 +1,108 @@ +import { InstancedMesh, BoxGeometry, Matrix4, Vector3, Quaternion } from 'three'; +import { VolumeStandardMaterial } from './VolumeStandardMaterial.js'; +import { VolumeGenerator } from './VolumeGenerator.js'; + +export class InstancedVolume extends InstancedMesh { + + constructor( count, params = {} ) { + + const geometry = new BoxGeometry( 1, 1, 1 ); + const material = new VolumeStandardMaterial( { + roughness: params.roughness !== undefined ? params.roughness : 1.0, + metalness: params.metalness !== undefined ? params.metalness : 1.0 + } ); + + super( geometry, material, count ); + + this.resolution = params.resolution !== undefined ? params.resolution : 100; + this.margin = params.margin !== undefined ? params.margin : 0.05; + this.surface = params.surface !== undefined ? params.surface : 0.0; + + this.sdfTexture = null; + this.inverseBoundsMatrix = new Matrix4(); + + } + + async generate( sourceMesh ) { + + // Dispose of the existing SDF texture + if ( this.sdfTexture ) { + + this.sdfTexture.dispose(); + + } + + // Generate the SDF using the shared generator + const result = await VolumeGenerator.generateSDF( sourceMesh, this.resolution, this.margin ); + this.sdfTexture = result.sdfTexture; + this.inverseBoundsMatrix = result.inverseBoundsMatrix; + + // Copy textures from source mesh material if available + if ( sourceMesh.material ) { + + const mat = sourceMesh.material; + if ( mat.map ) this.material.map = mat.map; + if ( mat.normalMap ) this.material.normalMap = mat.normalMap; + if ( mat.metalnessMap ) this.material.metalnessMap = mat.metalnessMap; + if ( mat.roughnessMap ) this.material.roughnessMap = mat.roughnessMap; + if ( mat.aoMap ) this.material.aoMap = mat.aoMap; + if ( mat.envMap ) this.material.envMap = mat.envMap; + this.material.needsUpdate = true; + + } + + // Set the mesh's scale to match SDF bounds + const sdfBoundsMatrix = this.inverseBoundsMatrix.clone().invert(); + const boundsCenter = new Vector3(); + const boundsQuat = new Quaternion(); + const boundsScale = new Vector3(); + sdfBoundsMatrix.decompose( boundsCenter, boundsQuat, boundsScale ); + + // For instanced mesh, we set the base scale + // Individual instances can be positioned using setMatrixAt + this.scale.copy( boundsScale ); + this.position.copy( boundsCenter ); + this.updateMatrix(); + + } + + onBeforeRender( renderer, scene, camera ) { + + if ( ! this.sdfTexture ) return; + + // Update matrices + camera.updateMatrixWorld(); + this.updateMatrixWorld(); + + const depth = 1 / this.resolution; + + // Update custom uniforms + this.material.uniforms.sdfTex.value = this.sdfTexture; + this.material.uniforms.normalStep.value.set( depth, depth, depth ); + this.material.uniforms.surface.value = this.surface; + + // Automatically use scene.environment if available + if ( scene.environment && ! this.material.envMap ) { + + this.material.envMap = scene.environment; + this.material.needsUpdate = true; + + } + + } + + dispose() { + + if ( this.sdfTexture ) { + + this.sdfTexture.dispose(); + this.sdfTexture = null; + + } + + this.geometry.dispose(); + this.material.dispose(); + + } + +} diff --git a/examples/jsm/utils/RenderSDFLayerMaterial.js b/examples/jsm/utils/RenderSDFLayerMaterial.js new file mode 100644 index 00000000000000..057b30ab2f0ef3 --- /dev/null +++ b/examples/jsm/utils/RenderSDFLayerMaterial.js @@ -0,0 +1,57 @@ +import { ShaderMaterial } from 'three'; + +export class RenderSDFLayerMaterial extends ShaderMaterial { + + constructor( params ) { + + super( { + uniforms: { + sdfTex: { value: null }, + layer: { value: 0 }, + }, + + vertexShader: /* glsl */` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); + } + `, + + fragmentShader: /* glsl */` + uniform sampler3D sdfTex; + uniform float layer; + varying vec2 vUv; + + void main() { + vec4 data = texture( sdfTex, vec3( vUv, layer ) ); + + // Display three channels side by side + vec3 color; + if ( vUv.x < 0.33 ) { + // Left third: Distance (grayscale, normalized around 0) + float dist = data.r; + float normalized = dist * 0.5 + 0.5; // Map -1,1 to 0,1 + color = vec3( normalized ); + } else if ( vUv.x < 0.66 ) { + // Middle third: U channel (red, fractional part to handle >1 values) + float u = fract( data.g ); + color = vec3( u, 0.0, 0.0 ); + } else { + // Right third: V channel (green, fractional part to handle >1 values) + float v = fract( data.b ); + color = vec3( 0.0, v, 0.0 ); + } + + gl_FragColor = vec4( color, 1.0 ); + + #include + } + ` + } ); + + this.setValues( params ); + + } + +} diff --git a/examples/jsm/utils/Volume.js b/examples/jsm/utils/Volume.js new file mode 100644 index 00000000000000..d97a0e95415c38 --- /dev/null +++ b/examples/jsm/utils/Volume.js @@ -0,0 +1,107 @@ +import { Mesh, BoxGeometry, Matrix4, Vector3, Quaternion } from 'three'; +import { VolumeStandardMaterial } from './VolumeStandardMaterial.js'; +import { VolumeGenerator } from './VolumeGenerator.js'; + +export class Volume extends Mesh { + + constructor( params = {} ) { + + const geometry = new BoxGeometry( 1, 1, 1 ); + const material = new VolumeStandardMaterial( { + roughness: params.roughness !== undefined ? params.roughness : 1.0, + metalness: params.metalness !== undefined ? params.metalness : 1.0 + } ); + + super( geometry, material ); + + this.resolution = params.resolution !== undefined ? params.resolution : 100; + this.margin = params.margin !== undefined ? params.margin : 0.05; + this.surface = params.surface !== undefined ? params.surface : 0.0; + + this.sdfTexture = null; + this.inverseBoundsMatrix = new Matrix4(); + + } + + async generate( sourceMesh ) { + + // Dispose of the existing SDF texture + if ( this.sdfTexture ) { + + this.sdfTexture.dispose(); + + } + + // Generate the SDF using the shared generator + const result = await VolumeGenerator.generateSDF( sourceMesh, this.resolution, this.margin ); + this.sdfTexture = result.sdfTexture; + this.inverseBoundsMatrix = result.inverseBoundsMatrix; + + // Copy textures from source mesh material if available + if ( sourceMesh.material ) { + + const mat = sourceMesh.material; + if ( mat.map ) this.material.map = mat.map; + if ( mat.normalMap ) this.material.normalMap = mat.normalMap; + if ( mat.metalnessMap ) this.material.metalnessMap = mat.metalnessMap; + if ( mat.roughnessMap ) this.material.roughnessMap = mat.roughnessMap; + if ( mat.aoMap ) this.material.aoMap = mat.aoMap; + if ( mat.envMap ) this.material.envMap = mat.envMap; + this.material.needsUpdate = true; + + } + + // Set the mesh's scale to match SDF bounds + const sdfBoundsMatrix = this.inverseBoundsMatrix.clone().invert(); + const boundsCenter = new Vector3(); + const boundsQuat = new Quaternion(); + const boundsScale = new Vector3(); + sdfBoundsMatrix.decompose( boundsCenter, boundsQuat, boundsScale ); + + // Apply scale and position + this.scale.copy( boundsScale ); + this.position.copy( boundsCenter ); + this.updateMatrixWorld(); + + } + + onBeforeRender( renderer, scene, camera ) { + + if ( ! this.sdfTexture ) return; + + // Update matrices + camera.updateMatrixWorld(); + this.updateMatrixWorld(); + + const depth = 1 / this.resolution; + + // Update custom uniforms + this.material.uniforms.sdfTex.value = this.sdfTexture; + this.material.uniforms.normalStep.value.set( depth, depth, depth ); + this.material.uniforms.surface.value = this.surface; + + // Automatically use scene.environment if available + if ( scene.environment && ! this.material.envMap ) { + + this.material.envMap = scene.environment; + this.material.needsUpdate = true; + + } + + } + + dispose() { + + if ( this.sdfTexture ) { + + this.sdfTexture.dispose(); + this.sdfTexture = null; + + } + + this.geometry.dispose(); + this.material.dispose(); + + } + +} diff --git a/examples/jsm/utils/VolumeGenerator.js b/examples/jsm/utils/VolumeGenerator.js new file mode 100644 index 00000000000000..a626555b53fd54 --- /dev/null +++ b/examples/jsm/utils/VolumeGenerator.js @@ -0,0 +1,174 @@ +import { Data3DTexture, RGBAFormat, FloatType, LinearFilter, Matrix4, Vector3, Vector2, Quaternion, Ray, DoubleSide, Triangle } from 'three'; + +export class VolumeGenerator { + + static async generateSDF( sourceMesh, resolution, margin ) { + + const dim = resolution; + const geometry = sourceMesh.geometry; + + // Ensure BVH is computed + if ( ! geometry.boundsTree ) { + + throw new Error( 'Source mesh geometry must have a BVH. Call geometry.computeBoundsTree() first.' ); + + } + + const bvh = geometry.boundsTree; + + const matrix = new Matrix4(); + const center = new Vector3(); + const quat = new Quaternion(); + const scale = new Vector3(); + + // Compute the bounding box of the geometry including the margin + if ( ! geometry.boundingBox ) geometry.computeBoundingBox(); + + geometry.boundingBox.getCenter( center ); + scale.subVectors( geometry.boundingBox.max, geometry.boundingBox.min ); + scale.x += 2 * margin; + scale.y += 2 * margin; + scale.z += 2 * margin; + matrix.compose( center, quat, scale ); + const inverseBoundsMatrix = new Matrix4().copy( matrix ).invert(); + + const pxWidth = 1 / dim; + const halfWidth = 0.5 * pxWidth; + + console.log( `Generating ${dim}x${dim}x${dim} SDF texture...` ); + + // Create a new 3D data texture + const sdfTexture = new Data3DTexture( new Float32Array( dim ** 3 * 4 ), dim, dim, dim ); + sdfTexture.format = RGBAFormat; + sdfTexture.type = FloatType; + sdfTexture.minFilter = LinearFilter; + sdfTexture.magFilter = LinearFilter; + + const point = new Vector3(); + const target = { + point: new Vector3(), + distance: 0, + faceIndex: - 1 + }; + const uvAttr = geometry.attributes.uv; + + // Reusable objects to avoid allocations in the loop + const ray = new Ray(); + const directions = [ + new Vector3( 1, 0, 0 ), + new Vector3( - 1, 0, 0 ), + new Vector3( 0, 1, 0 ), + new Vector3( 0, - 1, 0 ), + new Vector3( 0, 0, 1 ), + new Vector3( 0, 0, - 1 ) + ]; + const v0 = new Vector3(); + const v1 = new Vector3(); + const v2 = new Vector3(); + const barycoord = new Vector3(); + const uv0 = new Vector2(); + const uv1 = new Vector2(); + const uv2 = new Vector2(); + + // Iterate over all pixels and check distance + for ( let x = 0; x < dim; x ++ ) { + + if ( x % 10 === 0 ) { + + console.log( `Processing slice ${x}/${dim}...` ); + + } + + for ( let y = 0; y < dim; y ++ ) { + + for ( let z = 0; z < dim; z ++ ) { + + const index = ( x + dim * ( y + dim * z ) ) * 4; + + // Adjust by half width of the pixel so we sample the pixel center + // and offset by half the box size + point.set( + halfWidth + x * pxWidth - 0.5, + halfWidth + y * pxWidth - 0.5, + halfWidth + z * pxWidth - 0.5, + ).applyMatrix4( matrix ); + + // Get the distance to the geometry + bvh.closestPointToPoint( point, target ); + const dist = target.distance; + + // Check if the point is inside or outside by raycasting + // Skip expensive raycasts for points far from surface (definitely outside) + let isInside = false; + + if ( dist < margin ) { + + // If we hit a back face then we're inside + let insideCount = 0; + ray.origin.copy( point ); + + for ( let i = 0; i < 6; i ++ ) { + + ray.direction.copy( directions[ i ] ); + const hit = bvh.raycastFirst( ray, DoubleSide ); + if ( hit && hit.face.normal.dot( ray.direction ) > 0.0 ) { + + insideCount ++; + + } + + } + + isInside = insideCount > 3; + + } + + // Set the distance in the texture data + sdfTexture.image.data[ index + 0 ] = isInside ? - dist : dist; + + // Get UV from closest point + let u = 0, v = 0; + + if ( uvAttr && target.faceIndex !== undefined ) { + + const faceIndex = target.faceIndex; + const indexAttr = geometry.index; + const i0 = indexAttr.getX( faceIndex * 3 + 0 ); + const i1 = indexAttr.getX( faceIndex * 3 + 1 ); + const i2 = indexAttr.getX( faceIndex * 3 + 2 ); + + v0.fromBufferAttribute( geometry.attributes.position, i0 ); + v1.fromBufferAttribute( geometry.attributes.position, i1 ); + v2.fromBufferAttribute( geometry.attributes.position, i2 ); + + Triangle.getBarycoord( target.point, v0, v1, v2, barycoord ); + + uv0.fromBufferAttribute( uvAttr, i0 ); + uv1.fromBufferAttribute( uvAttr, i1 ); + uv2.fromBufferAttribute( uvAttr, i2 ); + + u = uv0.x * barycoord.x + uv1.x * barycoord.y + uv2.x * barycoord.z; + v = uv0.y * barycoord.x + uv1.y * barycoord.y + uv2.y * barycoord.z; + + } + + // Store UV in G and B channels + sdfTexture.image.data[ index + 1 ] = u; + sdfTexture.image.data[ index + 2 ] = v; + sdfTexture.image.data[ index + 3 ] = 0; // Alpha unused + + } + + } + + } + + sdfTexture.needsUpdate = true; + + console.log( 'SDF generation completed' ); + + return { sdfTexture, inverseBoundsMatrix }; + + } + +} diff --git a/examples/jsm/utils/VolumeStandardMaterial.js b/examples/jsm/utils/VolumeStandardMaterial.js new file mode 100644 index 00000000000000..771493b05cb24a --- /dev/null +++ b/examples/jsm/utils/VolumeStandardMaterial.js @@ -0,0 +1,241 @@ +import { MeshStandardMaterial, Vector3, BackSide } from 'three'; + +export class VolumeStandardMaterial extends MeshStandardMaterial { + + constructor( params ) { + + super( params ); + + this.side = BackSide; + + this.uniforms = { + sdfTex: { value: null }, + normalStep: { value: new Vector3() }, + surface: { value: 0 } + }; + + this.defines = { + MAX_STEPS: 50, + SURFACE_EPSILON: 0.0001 + }; + + this.onBeforeCompile = ( shader ) => { + + // Add our custom uniforms + shader.uniforms.sdfTex = this.uniforms.sdfTex; + shader.uniforms.normalStep = this.uniforms.normalStep; + shader.uniforms.surface = this.uniforms.surface; + + // Add our defines + shader.defines = shader.defines || {}; + Object.assign( shader.defines, this.defines ); + + // Modify vertex shader to compute ray in local space + shader.vertexShader = shader.vertexShader.replace( + '#include ', + `#include + varying vec3 vLocalPosition; + varying vec3 vLocalRayOrigin; + varying mat4 vInstanceMatrix;` + ); + + shader.vertexShader = shader.vertexShader.replace( + '#include ', + `#include + // Get the instance matrix (identity for non-instanced meshes) + #ifdef USE_INSTANCING + vInstanceMatrix = instanceMatrix; + #else + vInstanceMatrix = mat4( 1.0 ); + #endif + // Transform camera position to local space (accounting for instance transform) + vLocalRayOrigin = ( inverse( modelMatrix * vInstanceMatrix ) * vec4( cameraPosition, 1.0 ) ).xyz; + // Vertex position is already in local space + vLocalPosition = position;` + ); + + // Add custom uniforms and functions to fragment shader + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `#include + + uniform sampler3D sdfTex; + uniform vec3 normalStep; + uniform mat3 normalMatrix; + uniform mat4 modelViewMatrix; + uniform mat4 projectionMatrix; + uniform float surface; + + varying vec3 vLocalPosition; + varying vec3 vLocalRayOrigin; + varying mat4 vInstanceMatrix; + + vec2 rayBoxDist( vec3 boundsMin, vec3 boundsMax, vec3 rayOrigin, vec3 rayDir ) { + vec3 t0 = ( boundsMin - rayOrigin ) / rayDir; + vec3 t1 = ( boundsMax - rayOrigin ) / rayDir; + vec3 tmin = min( t0, t1 ); + vec3 tmax = max( t0, t1 ); + float distA = max( max( tmin.x, tmin.y ), tmin.z ); + float distB = min( tmax.x, min( tmax.y, tmax.z ) ); + float distToBox = max( 0.0, distA ); + float distInsideBox = max( 0.0, distB - distToBox ); + return vec2( distToBox, distInsideBox ); + }` + ); + + // Inject raymarching at the very start of main + shader.fragmentShader = shader.fragmentShader.replace( + 'void main() {', + `void main() { + // Raymarch from entry point to back face (current fragment) in local space + vec3 rayOrigin = vLocalRayOrigin; + vec3 rayDirection = normalize( vLocalPosition - vLocalRayOrigin ); + + // Find intersection with SDF bounds [-0.5, 0.5] + vec2 boxIntersectionInfo = rayBoxDist( vec3( - 0.5 ), vec3( 0.5 ), rayOrigin, rayDirection ); + float distToBox = boxIntersectionInfo.x; + float distInsideBox = boxIntersectionInfo.y; + + // Start from the entry point (or camera if inside) + distToBox = max( distToBox, 0.0 ); + + // Compute distance to back face (current fragment position) + float distToBackFace = length( vLocalPosition - rayOrigin ); + + // Raymarch from entry to back face to find surface in SDF + bool intersectsSurface = false; + vec3 localPoint = rayOrigin + rayDirection * distToBox; + float marchDist = distToBox; + + for ( int i = 0; i < MAX_STEPS; i ++ ) { + + // Stop if we've reached the back face + if ( marchDist >= distToBackFace ) { + break; + } + + vec3 sdfUV = localPoint + vec3( 0.5 ); + if ( sdfUV.x < 0.0 || sdfUV.x > 1.0 || sdfUV.y < 0.0 || sdfUV.y > 1.0 || sdfUV.z < 0.0 || sdfUV.z > 1.0 ) { + break; + } + + float distanceToSurface = texture( sdfTex, sdfUV ).r - surface; + if ( abs( distanceToSurface ) < SURFACE_EPSILON ) { + intersectsSurface = true; + break; + } + + float stepSize = distanceToSurface * 0.5; + localPoint += rayDirection * stepSize; + marchDist += stepSize; + } + + if ( !intersectsSurface ) { + discard; + } + + // Write correct depth for the raymarched surface (accounting for instance transform) + vec4 viewPos = modelViewMatrix * vInstanceMatrix * vec4( localPoint, 1.0 ); + vec4 clipPos = projectionMatrix * viewPos; + float ndcDepth = clipPos.z / clipPos.w; + gl_FragDepth = ndcDepth * 0.5 + 0.5; + + // Compute UV and normal from SDF + vec3 sdfUV = localPoint + vec3( 0.5 ); + vec4 sdfData = texture( sdfTex, sdfUV ); + vec2 sdfTexUv = sdfData.gb; + + // Compute gradient in SDF local space + float dx = texture( sdfTex, sdfUV + vec3( normalStep.x, 0.0, 0.0 ) ).r - texture( sdfTex, sdfUV - vec3( normalStep.x, 0.0, 0.0 ) ).r; + float dy = texture( sdfTex, sdfUV + vec3( 0.0, normalStep.y, 0.0 ) ).r - texture( sdfTex, sdfUV - vec3( 0.0, normalStep.y, 0.0 ) ).r; + float dz = texture( sdfTex, sdfUV + vec3( 0.0, 0.0, normalStep.z ) ).r - texture( sdfTex, sdfUV - vec3( 0.0, 0.0, normalStep.z ) ).r; + vec3 sdfNormalLocal = normalize( vec3( dx, dy, dz ) ); + + // Transform normal from SDF local space to view space (accounting for instance transform) + mat3 instanceNormalMatrix = mat3( transpose( inverse( vInstanceMatrix ) ) ); + vec3 sdfNormal = normalize( normalMatrix * instanceNormalMatrix * sdfNormalLocal ); + ` + ); + + // Replace UV sampling to use our computed UV + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `#ifdef USE_MAP + vec4 sampledDiffuseColor = texture2D( map, sdfTexUv ); + #ifdef DECODE_VIDEO_TEXTURE + sampledDiffuseColor = vec4( mix( pow( sampledDiffuseColor.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), sampledDiffuseColor.rgb * 0.0773993808, vec3( lessThanEqual( sampledDiffuseColor.rgb, vec3( 0.04045 ) ) ) ), sampledDiffuseColor.w ); + #endif + diffuseColor *= sampledDiffuseColor; + #endif` + ); + + // Replace normal mapping to use our computed UV and base normal + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `// Use the SDF normal (already in view space) + vec3 normal = sdfNormal; + vec3 nonPerturbedNormal = normal; + #ifdef FLAT_SHADED + normal = normalize( cross( dFdx( vViewPosition ), dFdy( vViewPosition ) ) ); + #endif` + ); + + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `#ifdef USE_NORMALMAP + // Sample the normal map + vec3 mapN = texture2D( normalMap, sdfTexUv ).xyz * 2.0 - 1.0; + mapN.xy *= normalScale; + + // Create a tangent space from the SDF normal + // We need to construct tangent and bitangent vectors perpendicular to the normal + vec3 N = normalize( normal ); + vec3 T = normalize( cross( N, vec3( 0.0, 1.0, 0.0 ) ) ); + // If normal is too close to (0,1,0), use a different reference + if ( length( T ) < 0.1 ) { + T = normalize( cross( N, vec3( 1.0, 0.0, 0.0 ) ) ); + } + vec3 B = normalize( cross( N, T ) ); + + // Apply normal map in tangent space + normal = normalize( T * mapN.x + B * mapN.y + N * mapN.z ); + #endif` + ); + + // Replace roughness/metalness sampling + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `float roughnessFactor = roughness; + #ifdef USE_ROUGHNESSMAP + vec4 texelRoughness = texture2D( roughnessMap, sdfTexUv ); + roughnessFactor *= texelRoughness.g; + #endif` + ); + + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `float metalnessFactor = metalness; + #ifdef USE_METALNESSMAP + vec4 texelMetalness = texture2D( metalnessMap, sdfTexUv ); + metalnessFactor *= texelMetalness.b; + #endif` + ); + + // Replace AO sampling + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `#ifdef USE_AOMAP + float ambientOcclusion = ( texture2D( aoMap, sdfTexUv ).r - 1.0 ) * aoMapIntensity + 1.0; + reflectedLight.indirectDiffuse *= ambientOcclusion; + #if defined( USE_ENVMAP ) && defined( STANDARD ) + float dotNV = saturate( dot( geometryNormal, geometryViewDir ) ); + reflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, ambientOcclusion, material.roughness ); + #endif + #endif` + ); + + }; + + } + +} diff --git a/examples/webgl_volume_mesh.html b/examples/webgl_volume_mesh.html new file mode 100644 index 00000000000000..99a3cd85dc5cf9 --- /dev/null +++ b/examples/webgl_volume_mesh.html @@ -0,0 +1,433 @@ + + + + three.js webgl - MeshVolume + + + + + + +
+ three.js webgl - MeshVolume
+ Generation time: - +
+ + + + + + +