diff --git a/src/rendering/fog-material.js b/src/rendering/fog-material.js new file mode 100644 index 00000000..5b386348 --- /dev/null +++ b/src/rendering/fog-material.js @@ -0,0 +1,220 @@ +import { BackSide, Color, ShaderMaterial, UniformsLib, UniformsUtils, Vector3 } from "three"; +const MIN_FOG_HALF_SIZE = 0.05; +export function createFogQualityMaterial(options) { + const halfSize = new Vector3(Math.max(MIN_FOG_HALF_SIZE, options.halfSize.x), Math.max(MIN_FOG_HALF_SIZE, options.halfSize.y), Math.max(MIN_FOG_HALF_SIZE, options.halfSize.z)); + const minHalfExtent = Math.min(halfSize.x, halfSize.y, halfSize.z); + const padding = Math.max(0, Math.min(options.padding, minHalfExtent * 0.82)); + const animationUniform = { value: options.time }; + const uniforms = UniformsUtils.clone(UniformsLib.fog); + uniforms["time"] = animationUniform; + uniforms["volumeFogColor"] = { value: new Color(options.colorHex) }; + uniforms["volumeFogDensity"] = { value: Math.max(0, options.density) }; + uniforms["volumeHalfSize"] = { value: halfSize }; + uniforms["volumePadding"] = { value: padding }; + uniforms["opacityMultiplier"] = { value: Math.max(0.6, Math.min(1.5, options.opacityMultiplier ?? 1)) }; + uniforms["colorLift"] = { value: Math.max(0, Math.min(0.22, options.colorLift ?? 0)) }; + const vertexShader = /* glsl */ ` + varying vec3 vLocalPosition; + #include + + void main() { + vLocalPosition = position; + vec4 worldPosition = modelMatrix * vec4(position, 1.0); + vec4 mvPosition = viewMatrix * worldPosition; + gl_Position = projectionMatrix * mvPosition; + #include + } + `; + const fragmentShader = /* glsl */ ` + uniform vec3 volumeFogColor; + uniform float volumeFogDensity; + uniform vec3 volumeHalfSize; + uniform float volumePadding; + uniform float opacityMultiplier; + uniform float colorLift; + uniform float time; + + varying vec3 vLocalPosition; + #include + + #define FOG_STEPS 18 + + float saturate(float value) { + return clamp(value, 0.0, 1.0); + } + + float hash13(vec3 point) { + point = fract(point * 0.1031); + point += dot(point, point.yzx + 33.33); + return fract((point.x + point.y) * point.z); + } + + float noise3(vec3 point) { + vec3 cell = floor(point); + vec3 local = fract(point); + vec3 smoothLocal = local * local * (3.0 - 2.0 * local); + + float n000 = hash13(cell + vec3(0.0, 0.0, 0.0)); + float n100 = hash13(cell + vec3(1.0, 0.0, 0.0)); + float n010 = hash13(cell + vec3(0.0, 1.0, 0.0)); + float n110 = hash13(cell + vec3(1.0, 1.0, 0.0)); + float n001 = hash13(cell + vec3(0.0, 0.0, 1.0)); + float n101 = hash13(cell + vec3(1.0, 0.0, 1.0)); + float n011 = hash13(cell + vec3(0.0, 1.0, 1.0)); + float n111 = hash13(cell + vec3(1.0, 1.0, 1.0)); + + float nx00 = mix(n000, n100, smoothLocal.x); + float nx10 = mix(n010, n110, smoothLocal.x); + float nx01 = mix(n001, n101, smoothLocal.x); + float nx11 = mix(n011, n111, smoothLocal.x); + float nxy0 = mix(nx00, nx10, smoothLocal.y); + float nxy1 = mix(nx01, nx11, smoothLocal.y); + return mix(nxy0, nxy1, smoothLocal.z); + } + + float fbm(vec3 point) { + float value = 0.0; + float amplitude = 0.5; + + for (int octave = 0; octave < 4; octave += 1) { + value += amplitude * noise3(point); + point = point * 2.02 + vec3(17.1, 31.7, 9.2); + amplitude *= 0.5; + } + + return value; + } + + vec2 intersectBox(vec3 rayOrigin, vec3 rayDirection, vec3 halfSize) { + vec3 safeDirection = sign(rayDirection) * max(abs(rayDirection), vec3(1e-4)); + vec3 invDirection = 1.0 / safeDirection; + vec3 t0 = (-halfSize - rayOrigin) * invDirection; + vec3 t1 = (halfSize - rayOrigin) * invDirection; + vec3 tMin = min(t0, t1); + vec3 tMax = max(t0, t1); + float nearHit = max(max(tMin.x, tMin.y), tMin.z); + float farHit = min(min(tMax.x, tMax.y), tMax.z); + return vec2(nearHit, farHit); + } + + float sampleShape(vec3 samplePosition) { + float minHalfExtent = min(min(volumeHalfSize.x, volumeHalfSize.y), volumeHalfSize.z); + float edgeSoftness = max(0.08, min(volumePadding + minHalfExtent * 0.16, minHalfExtent * 0.72)); + vec3 innerHalfSize = max(volumeHalfSize - vec3(edgeSoftness), vec3(minHalfExtent * 0.18)); + vec3 distanceToCore = abs(samplePosition) - innerHalfSize; + float outsideDistance = length(max(distanceToCore, 0.0)); + float insideDistance = min(max(distanceToCore.x, max(distanceToCore.y, distanceToCore.z)), 0.0); + float roundedBoxDistance = outsideDistance + insideDistance; + float edgeMask = 1.0 - smoothstep(-edgeSoftness * 0.7, edgeSoftness * 1.35, roundedBoxDistance); + + vec3 ellipsoidPosition = samplePosition / max(volumeHalfSize - vec3(edgeSoftness * 0.18), vec3(1e-3)); + float roundedMask = 1.0 - smoothstep(0.54, 1.03, length(ellipsoidPosition * vec3(0.96, 1.08, 0.96))); + + return edgeMask * mix(0.42, 1.0, roundedMask); + } + + float sampleVolumeDensity(vec3 samplePosition) { + vec3 normalizedPosition = samplePosition / max(volumeHalfSize, vec3(1e-3)); + float shape = sampleShape(samplePosition); + + if (shape <= 1e-3) { + return 0.0; + } + + vec3 drift = vec3(time * 0.12, time * 0.05, -time * 0.08); + vec3 warpSource = samplePosition * 0.65 + drift; + vec3 warp = vec3( + fbm(warpSource + vec3(13.1, 0.0, 0.0)), + fbm(warpSource + vec3(0.0, 7.9, 0.0)), + fbm(warpSource + vec3(0.0, 0.0, 19.7)) + ) - 0.5; + vec3 cloudPosition = samplePosition + warp * (0.7 + shape * 0.5); + + float primary = fbm(cloudPosition * 0.78 + drift); + float secondary = fbm(cloudPosition * 1.56 - drift * 1.35); + float wisps = fbm(cloudPosition * 2.35 + vec3(0.0, time * 0.09, 0.0)); + float cloud = smoothstep(0.28, 0.94, mix(primary, secondary, 0.45) + wisps * 0.18); + float centerBias = 1.0 - smoothstep(0.18, 1.08, length(normalizedPosition * vec3(1.05, 0.92, 1.05))); + float verticalBias = mix(0.9, 1.08, smoothstep(-0.75, 0.35, normalizedPosition.y)); + float carvedCloud = mix(0.35, 1.1, cloud) * mix(0.72, 1.0, centerBias); + + return volumeFogDensity * shape * carvedCloud * verticalBias; + } + + void main() { + vec3 worldOrigin = (modelMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz; + mat3 localToWorld = mat3(modelMatrix); + vec3 worldCameraOffset = cameraPosition - worldOrigin; + vec3 localCameraPosition = vec3( + dot(worldCameraOffset, localToWorld[0]), + dot(worldCameraOffset, localToWorld[1]), + dot(worldCameraOffset, localToWorld[2]) + ); + vec3 rayDirection = normalize(vLocalPosition - localCameraPosition); + vec2 hitRange = intersectBox(localCameraPosition, rayDirection, volumeHalfSize); + float startDistance = max(hitRange.x, 0.0); + float endDistance = hitRange.y; + + if (endDistance <= startDistance) { + discard; + } + + float rayLength = endDistance - startDistance; + float stepLength = rayLength / float(FOG_STEPS); + float jitter = hash13(vLocalPosition * 1.73 + vec3(time * 0.17)) - 0.5; + float transmittance = 1.0; + vec3 accumulatedColor = vec3(0.0); + + for (int stepIndex = 0; stepIndex < FOG_STEPS; stepIndex += 1) { + float sampleDistance = startDistance + (float(stepIndex) + 0.5 + jitter * 0.35) * stepLength; + vec3 samplePosition = localCameraPosition + rayDirection * sampleDistance; + float sampleDensity = sampleVolumeDensity(samplePosition); + + if (sampleDensity <= 1e-4) { + continue; + } + + vec3 normalizedPosition = samplePosition / max(volumeHalfSize, vec3(1e-3)); + float forwardScatter = pow(1.0 - abs(dot(rayDirection, normalize(samplePosition + vec3(1e-3, 2e-3, -1e-3)))), 2.0); + float topLight = smoothstep(-0.2, 0.95, normalizedPosition.y); + float coolShadow = smoothstep(0.15, 0.9, fbm(samplePosition * 0.92 - vec3(time * 0.11, 0.0, time * 0.06))); + vec3 sampleColor = mix(volumeFogColor * 0.74, vec3(1.0), 0.08 + topLight * 0.12 + forwardScatter * 0.18); + sampleColor = mix(sampleColor * 0.92, sampleColor, coolShadow); + + float extinction = sampleDensity * stepLength * 1.65; + float sampleAlpha = 1.0 - exp(-extinction); + accumulatedColor += transmittance * sampleColor * sampleAlpha; + transmittance *= 1.0 - sampleAlpha; + + if (transmittance < 0.03) { + break; + } + } + + float baseAlpha = 1.0 - transmittance; + float alpha = clamp(baseAlpha * opacityMultiplier, 0.0, 0.96); + + if (alpha <= 0.01) { + discard; + } + + vec3 color = accumulatedColor / max(baseAlpha, 1e-4); + color = mix(color, vec3(1.0), colorLift); + + gl_FragColor = vec4(color, alpha); + #include + } + `; + return { + material: new ShaderMaterial({ + vertexShader, + fragmentShader, + uniforms, + transparent: true, + depthWrite: false, + fog: true, + side: BackSide + }), + animationUniform + }; +} \ No newline at end of file diff --git a/src/rendering/fog-material.ts b/src/rendering/fog-material.ts new file mode 100644 index 00000000..eec128fc --- /dev/null +++ b/src/rendering/fog-material.ts @@ -0,0 +1,249 @@ +import { BackSide, Color, ShaderMaterial, UniformsLib, UniformsUtils, Vector3 } from "three"; + +export interface FogQualityMaterialOptions { + colorHex: string; + density: number; + padding: number; + time: number; + halfSize: { + x: number; + y: number; + z: number; + }; + opacityMultiplier?: number; + colorLift?: number; +} + +export interface FogQualityMaterialResult { + material: ShaderMaterial; + animationUniform: { value: number }; +} + +const MIN_FOG_HALF_SIZE = 0.05; + +export function createFogQualityMaterial(options: FogQualityMaterialOptions): FogQualityMaterialResult { + const halfSize = new Vector3( + Math.max(MIN_FOG_HALF_SIZE, options.halfSize.x), + Math.max(MIN_FOG_HALF_SIZE, options.halfSize.y), + Math.max(MIN_FOG_HALF_SIZE, options.halfSize.z) + ); + const minHalfExtent = Math.min(halfSize.x, halfSize.y, halfSize.z); + const padding = Math.max(0, Math.min(options.padding, minHalfExtent * 0.82)); + const animationUniform = { value: options.time }; + const uniforms = UniformsUtils.clone(UniformsLib.fog) as Record; + + uniforms["time"] = animationUniform; + uniforms["volumeFogColor"] = { value: new Color(options.colorHex) }; + uniforms["volumeFogDensity"] = { value: Math.max(0, options.density) }; + uniforms["volumeHalfSize"] = { value: halfSize }; + uniforms["volumePadding"] = { value: padding }; + uniforms["opacityMultiplier"] = { value: Math.max(0.6, Math.min(1.5, options.opacityMultiplier ?? 1)) }; + uniforms["colorLift"] = { value: Math.max(0, Math.min(0.22, options.colorLift ?? 0)) }; + + const vertexShader = /* glsl */ ` + varying vec3 vLocalPosition; + #include + + void main() { + vLocalPosition = position; + vec4 worldPosition = modelMatrix * vec4(position, 1.0); + vec4 mvPosition = viewMatrix * worldPosition; + gl_Position = projectionMatrix * mvPosition; + #include + } + `; + + const fragmentShader = /* glsl */ ` + uniform vec3 volumeFogColor; + uniform float volumeFogDensity; + uniform vec3 volumeHalfSize; + uniform float volumePadding; + uniform float opacityMultiplier; + uniform float colorLift; + uniform float time; + + varying vec3 vLocalPosition; + #include + + #define FOG_STEPS 18 + + float saturate(float value) { + return clamp(value, 0.0, 1.0); + } + + float hash13(vec3 point) { + point = fract(point * 0.1031); + point += dot(point, point.yzx + 33.33); + return fract((point.x + point.y) * point.z); + } + + float noise3(vec3 point) { + vec3 cell = floor(point); + vec3 local = fract(point); + vec3 smoothLocal = local * local * (3.0 - 2.0 * local); + + float n000 = hash13(cell + vec3(0.0, 0.0, 0.0)); + float n100 = hash13(cell + vec3(1.0, 0.0, 0.0)); + float n010 = hash13(cell + vec3(0.0, 1.0, 0.0)); + float n110 = hash13(cell + vec3(1.0, 1.0, 0.0)); + float n001 = hash13(cell + vec3(0.0, 0.0, 1.0)); + float n101 = hash13(cell + vec3(1.0, 0.0, 1.0)); + float n011 = hash13(cell + vec3(0.0, 1.0, 1.0)); + float n111 = hash13(cell + vec3(1.0, 1.0, 1.0)); + + float nx00 = mix(n000, n100, smoothLocal.x); + float nx10 = mix(n010, n110, smoothLocal.x); + float nx01 = mix(n001, n101, smoothLocal.x); + float nx11 = mix(n011, n111, smoothLocal.x); + float nxy0 = mix(nx00, nx10, smoothLocal.y); + float nxy1 = mix(nx01, nx11, smoothLocal.y); + return mix(nxy0, nxy1, smoothLocal.z); + } + + float fbm(vec3 point) { + float value = 0.0; + float amplitude = 0.5; + + for (int octave = 0; octave < 4; octave += 1) { + value += amplitude * noise3(point); + point = point * 2.02 + vec3(17.1, 31.7, 9.2); + amplitude *= 0.5; + } + + return value; + } + + vec2 intersectBox(vec3 rayOrigin, vec3 rayDirection, vec3 halfSize) { + vec3 safeDirection = sign(rayDirection) * max(abs(rayDirection), vec3(1e-4)); + vec3 invDirection = 1.0 / safeDirection; + vec3 t0 = (-halfSize - rayOrigin) * invDirection; + vec3 t1 = (halfSize - rayOrigin) * invDirection; + vec3 tMin = min(t0, t1); + vec3 tMax = max(t0, t1); + float nearHit = max(max(tMin.x, tMin.y), tMin.z); + float farHit = min(min(tMax.x, tMax.y), tMax.z); + return vec2(nearHit, farHit); + } + + float sampleShape(vec3 samplePosition) { + float minHalfExtent = min(min(volumeHalfSize.x, volumeHalfSize.y), volumeHalfSize.z); + float edgeSoftness = max(0.08, min(volumePadding + minHalfExtent * 0.16, minHalfExtent * 0.72)); + vec3 innerHalfSize = max(volumeHalfSize - vec3(edgeSoftness), vec3(minHalfExtent * 0.18)); + vec3 distanceToCore = abs(samplePosition) - innerHalfSize; + float outsideDistance = length(max(distanceToCore, 0.0)); + float insideDistance = min(max(distanceToCore.x, max(distanceToCore.y, distanceToCore.z)), 0.0); + float roundedBoxDistance = outsideDistance + insideDistance; + float edgeMask = 1.0 - smoothstep(-edgeSoftness * 0.7, edgeSoftness * 1.35, roundedBoxDistance); + + vec3 ellipsoidPosition = samplePosition / max(volumeHalfSize - vec3(edgeSoftness * 0.18), vec3(1e-3)); + float roundedMask = 1.0 - smoothstep(0.54, 1.03, length(ellipsoidPosition * vec3(0.96, 1.08, 0.96))); + + return edgeMask * mix(0.42, 1.0, roundedMask); + } + + float sampleVolumeDensity(vec3 samplePosition) { + vec3 normalizedPosition = samplePosition / max(volumeHalfSize, vec3(1e-3)); + float shape = sampleShape(samplePosition); + + if (shape <= 1e-3) { + return 0.0; + } + + vec3 drift = vec3(time * 0.12, time * 0.05, -time * 0.08); + vec3 warpSource = samplePosition * 0.65 + drift; + vec3 warp = vec3( + fbm(warpSource + vec3(13.1, 0.0, 0.0)), + fbm(warpSource + vec3(0.0, 7.9, 0.0)), + fbm(warpSource + vec3(0.0, 0.0, 19.7)) + ) - 0.5; + vec3 cloudPosition = samplePosition + warp * (0.7 + shape * 0.5); + + float primary = fbm(cloudPosition * 0.78 + drift); + float secondary = fbm(cloudPosition * 1.56 - drift * 1.35); + float wisps = fbm(cloudPosition * 2.35 + vec3(0.0, time * 0.09, 0.0)); + float cloud = smoothstep(0.28, 0.94, mix(primary, secondary, 0.45) + wisps * 0.18); + float centerBias = 1.0 - smoothstep(0.18, 1.08, length(normalizedPosition * vec3(1.05, 0.92, 1.05))); + float verticalBias = mix(0.9, 1.08, smoothstep(-0.75, 0.35, normalizedPosition.y)); + float carvedCloud = mix(0.35, 1.1, cloud) * mix(0.72, 1.0, centerBias); + + return volumeFogDensity * shape * carvedCloud * verticalBias; + } + + void main() { + vec3 worldOrigin = (modelMatrix * vec4(0.0, 0.0, 0.0, 1.0)).xyz; + mat3 localToWorld = mat3(modelMatrix); + vec3 worldCameraOffset = cameraPosition - worldOrigin; + vec3 localCameraPosition = vec3( + dot(worldCameraOffset, localToWorld[0]), + dot(worldCameraOffset, localToWorld[1]), + dot(worldCameraOffset, localToWorld[2]) + ); + vec3 rayDirection = normalize(vLocalPosition - localCameraPosition); + vec2 hitRange = intersectBox(localCameraPosition, rayDirection, volumeHalfSize); + float startDistance = max(hitRange.x, 0.0); + float endDistance = hitRange.y; + + if (endDistance <= startDistance) { + discard; + } + + float rayLength = endDistance - startDistance; + float stepLength = rayLength / float(FOG_STEPS); + float jitter = hash13(vLocalPosition * 1.73 + vec3(time * 0.17)) - 0.5; + float transmittance = 1.0; + vec3 accumulatedColor = vec3(0.0); + + for (int stepIndex = 0; stepIndex < FOG_STEPS; stepIndex += 1) { + float sampleDistance = startDistance + (float(stepIndex) + 0.5 + jitter * 0.35) * stepLength; + vec3 samplePosition = localCameraPosition + rayDirection * sampleDistance; + float sampleDensity = sampleVolumeDensity(samplePosition); + + if (sampleDensity <= 1e-4) { + continue; + } + + vec3 normalizedPosition = samplePosition / max(volumeHalfSize, vec3(1e-3)); + float forwardScatter = pow(1.0 - abs(dot(rayDirection, normalize(samplePosition + vec3(1e-3, 2e-3, -1e-3)))), 2.0); + float topLight = smoothstep(-0.2, 0.95, normalizedPosition.y); + float coolShadow = smoothstep(0.15, 0.9, fbm(samplePosition * 0.92 - vec3(time * 0.11, 0.0, time * 0.06))); + vec3 sampleColor = mix(volumeFogColor * 0.74, vec3(1.0), 0.08 + topLight * 0.12 + forwardScatter * 0.18); + sampleColor = mix(sampleColor * 0.92, sampleColor, coolShadow); + + float extinction = sampleDensity * stepLength * 1.65; + float sampleAlpha = 1.0 - exp(-extinction); + accumulatedColor += transmittance * sampleColor * sampleAlpha; + transmittance *= 1.0 - sampleAlpha; + + if (transmittance < 0.03) { + break; + } + } + + float baseAlpha = 1.0 - transmittance; + float alpha = clamp(baseAlpha * opacityMultiplier, 0.0, 0.96); + + if (alpha <= 0.01) { + discard; + } + + vec3 color = accumulatedColor / max(baseAlpha, 1e-4); + color = mix(color, vec3(1.0), colorLift); + + gl_FragColor = vec4(color, alpha); + #include + } + `; + + return { + material: new ShaderMaterial({ + vertexShader, + fragmentShader, + uniforms, + transparent: true, + depthWrite: false, + fog: true, + side: BackSide + }), + animationUniform + }; +} \ No newline at end of file diff --git a/src/runtime-three/runtime-host.js b/src/runtime-three/runtime-host.js index cc1d44b1..147bcc14 100644 --- a/src/runtime-three/runtime-host.js +++ b/src/runtime-three/runtime-host.js @@ -1,8 +1,9 @@ -import { AmbientLight, AnimationClip, AnimationMixer, DirectionalLight, Euler, FogExp2, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, PointLight, Quaternion, Scene, ShaderMaterial, Vector3, SpotLight, UniformsLib, UniformsUtils, WebGLRenderTarget, WebGLRenderer } from "three"; +import { AmbientLight, AnimationClip, AnimationMixer, DirectionalLight, Euler, FogExp2, Group, LoopOnce, LoopRepeat, Mesh, MeshBasicMaterial, MeshStandardMaterial, PerspectiveCamera, PointLight, Quaternion, Scene, Vector3, SpotLight, WebGLRenderTarget, WebGLRenderer } from "three"; import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering"; import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh"; import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures"; import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer, resolveBoxVolumeRenderPaths } from "../rendering/advanced-rendering"; +import { createFogQualityMaterial } from "../rendering/fog-material"; import { collectWaterContactPatches, createWaterContactPatchAxisUniformValue, createWaterContactPatchShapeUniformValue, createWaterContactPatchUniformValue, createWaterMaterial } from "../rendering/water-material"; import { updatePlanarReflectionCamera } from "../rendering/planar-reflection"; import { areAdvancedRenderingSettingsEqual, cloneAdvancedRenderingSettings } from "../document/world-settings"; @@ -34,7 +35,6 @@ export class RuntimeHost { underwaterSceneFog = new FogExp2("#2c6f8d", 0.03); brushMeshes = new Map(); volumeTime = 0; - volumeAnimatedMaterials = []; volumeAnimatedUniforms = []; runtimeWaterContactUniforms = []; localLightObjects = new Map(); @@ -472,7 +472,19 @@ export class RuntimeHost { } if (brush.volume.mode === "fog") { if (volumeRenderPaths.fog === "quality") { - return this.createFogQualityMaterial(brush.volume.fog); + const fogMaterial = createFogQualityMaterial({ + colorHex: brush.volume.fog.colorHex, + density: brush.volume.fog.density, + padding: brush.volume.fog.padding, + time: this.volumeTime, + halfSize: { + x: brush.size.x * 0.5, + y: brush.size.y * 0.5, + z: brush.size.z * 0.5 + } + }); + this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); + return fogMaterial.material; } const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)); return new MeshBasicMaterial({ @@ -496,60 +508,6 @@ export class RuntimeHost { metalness: 0.02 }); } - createFogQualityMaterial(fog) { - const hex = fog.colorHex.replace("#", ""); - const cr = parseInt(hex.substring(0, 2), 16) / 255; - const cg = parseInt(hex.substring(2, 4), 16) / 255; - const cb = parseInt(hex.substring(4, 6), 16) / 255; - const vertexShader = ` - varying vec2 vUv; - #include - void main() { - vUv = uv; - vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); - gl_Position = projectionMatrix * mvPosition; - #include - } - `; - const fragmentShader = ` - uniform vec3 volumeFogColor; - uniform float volumeFogDensity; - uniform float time; - varying vec2 vUv; - #include - void main() { - vec2 dist = abs(vUv - 0.5) * 2.0; - float edgeFade = 1.0 - smoothstep(0.4, 1.0, max(dist.x, dist.y)); - float drift = sin(vUv.x * 4.5 + time * 0.28) * sin(vUv.y * 3.2 + time * 0.22); - float variation = 0.82 + drift * 0.18; - float alpha = volumeFogDensity * edgeFade * variation; - alpha = clamp(alpha, 0.0, 0.88); - vec3 color = mix(volumeFogColor, vec3(1.0), (1.0 - edgeFade) * 0.09); - gl_FragColor = vec4(color, alpha); - #include - } - `; - const uniforms = UniformsUtils.merge([ - UniformsLib.fog, - { - volumeFogColor: { value: [cr, cg, cb] }, - volumeFogDensity: { value: Math.min(0.9, fog.density + 0.12) }, - time: { value: this.volumeTime } - } - ]); - const mat = new ShaderMaterial({ - vertexShader, - fragmentShader, - uniforms, - transparent: true, - depthWrite: false, - fog: true, - side: 2 - }); - this.volumeAnimatedMaterials.push(mat); - return mat; - } - updateUnderwaterSceneFog() { const fogState = this.activeController === this.firstPersonController ? resolveUnderwaterFogState(this.runtimeScene, this.currentFirstPersonTelemetry) : null; @@ -699,7 +657,6 @@ export class RuntimeHost { } } this.brushMeshes.clear(); - this.volumeAnimatedMaterials.length = 0; this.volumeAnimatedUniforms.length = 0; for (const binding of this.runtimeWaterContactUniforms) { binding.reflectionRenderTarget?.dispose(); @@ -851,9 +808,6 @@ export class RuntimeHost { this.activeController?.update(dt); this.audioSystem.updateListenerTransform(); this.volumeTime += dt; - for (const mat of this.volumeAnimatedMaterials) { - mat.uniforms["time"].value = this.volumeTime; - } for (const uniform of this.volumeAnimatedUniforms) { uniform.value = this.volumeTime; } diff --git a/src/runtime-three/runtime-host.ts b/src/runtime-three/runtime-host.ts index 9670cdad..4c257ec9 100644 --- a/src/runtime-three/runtime-host.ts +++ b/src/runtime-three/runtime-host.ts @@ -18,11 +18,8 @@ import { PointLight, Quaternion, Scene, - ShaderMaterial, Vector3, SpotLight, - UniformsLib, - UniformsUtils, WebGLRenderTarget, WebGLRenderer } from "three"; @@ -50,6 +47,7 @@ import { createWaterContactPatchUniformValue, createWaterMaterial } from "../rendering/water-material"; +import { createFogQualityMaterial } from "../rendering/fog-material"; import { updatePlanarReflectionCamera } from "../rendering/planar-reflection"; import { areAdvancedRenderingSettingsEqual, @@ -117,7 +115,6 @@ export class RuntimeHost { private readonly waterReflectionCamera = new PerspectiveCamera(); private readonly brushMeshes = new Map>(); private volumeTime = 0; - private readonly volumeAnimatedMaterials: ShaderMaterial[] = []; private readonly volumeAnimatedUniforms: Array<{ value: number }> = []; private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] = []; private readonly localLightObjects = new Map(); @@ -685,7 +682,20 @@ export class RuntimeHost { if (brush.volume.mode === "fog") { if (volumeRenderPaths.fog === "quality") { - return this.createFogQualityMaterial(brush.volume.fog); + const fogMaterial = createFogQualityMaterial({ + colorHex: brush.volume.fog.colorHex, + density: brush.volume.fog.density, + padding: brush.volume.fog.padding, + time: this.volumeTime, + halfSize: { + x: brush.size.x * 0.5, + y: brush.size.y * 0.5, + z: brush.size.z * 0.5 + } + }); + + this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); + return fogMaterial.material; } // Performance fallback: simple transparent material const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)); @@ -713,72 +723,6 @@ export class RuntimeHost { }); } - // Soft edge-faded fog shader with slow drift animation — quality mode only. - private createFogQualityMaterial(fog: { colorHex: string; density: number }): ShaderMaterial { - const hex = fog.colorHex.replace("#", ""); - const cr = parseInt(hex.substring(0, 2), 16) / 255; - const cg = parseInt(hex.substring(2, 4), 16) / 255; - const cb = parseInt(hex.substring(4, 6), 16) / 255; - - const vertexShader = /* glsl */ ` - varying vec2 vUv; - #include - void main() { - vUv = uv; - vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); - gl_Position = projectionMatrix * mvPosition; - #include - } - `; - - const fragmentShader = /* glsl */ ` - uniform vec3 volumeFogColor; - uniform float volumeFogDensity; - uniform float time; - varying vec2 vUv; - #include - - void main() { - // Soft fade: distance from the center of each face in UV space - vec2 dist = abs(vUv - 0.5) * 2.0; // 0 at center, 1 at edge - float edgeFade = 1.0 - smoothstep(0.4, 1.0, max(dist.x, dist.y)); - - // Slow drifting noise to break up the flat look - float drift = sin(vUv.x * 4.5 + time * 0.28) * sin(vUv.y * 3.2 + time * 0.22); - float variation = 0.82 + drift * 0.18; - - float alpha = volumeFogDensity * edgeFade * variation; - alpha = clamp(alpha, 0.0, 0.88); - - // Edge scatter brightening - vec3 color = mix(volumeFogColor, vec3(1.0), (1.0 - edgeFade) * 0.09); - gl_FragColor = vec4(color, alpha); - #include - } - `; - - const uniforms = UniformsUtils.merge([ - UniformsLib.fog, - { - volumeFogColor: { value: [cr, cg, cb] }, - volumeFogDensity: { value: Math.min(0.9, fog.density + 0.12) }, - time: { value: this.volumeTime } - } - ]); - - const mat = new ShaderMaterial({ - vertexShader, - fragmentShader, - uniforms, - transparent: true, - depthWrite: false, - fog: true, - side: 2 // THREE.DoubleSide — fog is visible from inside too - }); - this.volumeAnimatedMaterials.push(mat); - return mat; - } - private updateUnderwaterSceneFog() { const fogState = this.activeController === this.firstPersonController @@ -968,7 +912,6 @@ export class RuntimeHost { } this.brushMeshes.clear(); - this.volumeAnimatedMaterials.length = 0; this.volumeAnimatedUniforms.length = 0; for (const binding of this.runtimeWaterContactUniforms) { binding.reflectionRenderTarget?.dispose(); @@ -1164,9 +1107,6 @@ export class RuntimeHost { this.audioSystem.updateListenerTransform(); this.volumeTime += dt; - for (const mat of this.volumeAnimatedMaterials) { - (mat.uniforms["time"] as { value: number }).value = this.volumeTime; - } for (const uniform of this.volumeAnimatedUniforms) { uniform.value = this.volumeTime; } diff --git a/src/viewport-three/viewport-host.js b/src/viewport-three/viewport-host.js index e3ee3909..1ff912a0 100644 --- a/src/viewport-three/viewport-host.js +++ b/src/viewport-three/viewport-host.js @@ -14,6 +14,7 @@ import { buildGeneratedModelCollider } from "../geometry/model-instance-collider import { DEFAULT_GRID_SIZE, snapValueToGrid } from "../geometry/grid-snapping"; import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures"; import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer, resolveBoxVolumeRenderPaths } from "../rendering/advanced-rendering"; +import { createFogQualityMaterial } from "../rendering/fog-material"; import { updatePlanarReflectionCamera } from "../rendering/planar-reflection"; import { collectWaterContactPatches, createWaterMaterial } from "../rendering/water-material"; import { resolveViewportFocusTarget } from "./viewport-focus"; @@ -2157,6 +2158,23 @@ export class ViewportHost { opacity }); } + if (quality) { + const fogMaterial = createFogQualityMaterial({ + colorHex: brush.volume.fog.colorHex, + density: brush.volume.fog.density * (selectedFace ? 1.12 : hoveredFace ? 1.06 : 1), + padding: brush.volume.fog.padding, + time: this.volumeTime, + halfSize: { + x: brush.size.x * 0.5, + y: brush.size.y * 0.5, + z: brush.size.z * 0.5 + }, + opacityMultiplier: selectedFace ? 1.12 : hoveredFace ? 1.06 : 1, + colorLift: selectedFace ? 0.08 : hoveredFace ? 0.04 : 0 + }); + this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); + return fogMaterial.material; + } return new MeshStandardMaterial({ color: brush.volume.fog.colorHex, emissive: brush.volume.fog.colorHex, diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index 1c35733f..06641cc5 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -148,6 +148,7 @@ import { createAdvancedRenderingComposer, resolveBoxVolumeRenderPaths } from "../rendering/advanced-rendering"; +import { createFogQualityMaterial } from "../rendering/fog-material"; import { updatePlanarReflectionCamera } from "../rendering/planar-reflection"; import { collectWaterContactPatches, createWaterMaterial } from "../rendering/water-material"; import { resolveViewportFocusTarget } from "./viewport-focus"; @@ -2963,6 +2964,25 @@ export class ViewportHost { }); } + if (quality) { + const fogMaterial = createFogQualityMaterial({ + colorHex: brush.volume.fog.colorHex, + density: brush.volume.fog.density * (selectedFace ? 1.12 : hoveredFace ? 1.06 : 1), + padding: brush.volume.fog.padding, + time: this.volumeTime, + halfSize: { + x: brush.size.x * 0.5, + y: brush.size.y * 0.5, + z: brush.size.z * 0.5 + }, + opacityMultiplier: selectedFace ? 1.12 : hoveredFace ? 1.06 : 1, + colorLift: selectedFace ? 0.08 : hoveredFace ? 0.04 : 0 + }); + + this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); + return fogMaterial.material; + } + return new MeshStandardMaterial({ color: brush.volume.fog.colorHex, emissive: brush.volume.fog.colorHex, diff --git a/tests/domain/fog-material.test.ts b/tests/domain/fog-material.test.ts new file mode 100644 index 00000000..0773ed45 --- /dev/null +++ b/tests/domain/fog-material.test.ts @@ -0,0 +1,60 @@ +import { BackSide, ShaderMaterial } from "three"; +import { describe, expect, it } from "vitest"; + +import { createFogQualityMaterial } from "../../src/rendering/fog-material"; + +describe("fog quality material", () => { + it("builds a raymarched volumetric shader that stays animated through a shared time uniform", () => { + const result = createFogQualityMaterial({ + colorHex: "#99aac4", + density: 0.55, + padding: 0.25, + time: 1.5, + halfSize: { + x: 2, + y: 1.5, + z: 1 + } + }); + + expect(result.material).toBeInstanceOf(ShaderMaterial); + + const material = result.material as ShaderMaterial; + expect(material.transparent).toBe(true); + expect(material.depthWrite).toBe(false); + expect(material.fog).toBe(true); + expect(material.side).toBe(BackSide); + expect(material.uniforms["volumeFogDensity"]?.value).toBe(0.55); + expect(material.uniforms["volumePadding"]?.value).toBeCloseTo(0.25, 5); + expect(material.uniforms["volumeHalfSize"]?.value).toMatchObject({ x: 2, y: 1.5, z: 1 }); + expect(material.fragmentShader).toContain("intersectBox"); + expect(material.fragmentShader).toContain("sampleVolumeDensity"); + expect(material.fragmentShader).toContain("FOG_STEPS 18"); + expect(result.animationUniform).toBe(material.uniforms["time"]); + + result.animationUniform.value = 3.25; + expect(material.uniforms["time"]?.value).toBe(3.25); + }); + + it("clamps oversized padding and exposes viewport emphasis controls", () => { + const result = createFogQualityMaterial({ + colorHex: "#c2d8f4", + density: 0.4, + padding: 10, + time: 0, + halfSize: { + x: 1, + y: 0.5, + z: 0.75 + }, + opacityMultiplier: 1.3, + colorLift: 0.2 + }); + + const material = result.material as ShaderMaterial; + expect(material.uniforms["volumePadding"]?.value).toBeCloseTo(0.41, 5); + expect(material.uniforms["opacityMultiplier"]?.value).toBe(1.3); + expect(material.uniforms["colorLift"]?.value).toBe(0.2); + expect(material.vertexShader).toContain("vLocalPosition"); + }); +}); \ No newline at end of file