Add fog quality material and update runtime/viewport hosts

This commit is contained in:
2026-04-07 11:07:19 +02:00
parent 802d3e026c
commit 40a53801a7
7 changed files with 597 additions and 136 deletions

View File

@@ -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 <fog_pars_vertex>
void main() {
vLocalPosition = position;
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vec4 mvPosition = viewMatrix * worldPosition;
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}
`;
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 <fog_pars_fragment>
#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 <fog_fragment>
}
`;
return {
material: new ShaderMaterial({
vertexShader,
fragmentShader,
uniforms,
transparent: true,
depthWrite: false,
fog: true,
side: BackSide
}),
animationUniform
};
}

View File

@@ -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<string, { value: unknown }>;
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 <fog_pars_vertex>
void main() {
vLocalPosition = position;
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vec4 mvPosition = viewMatrix * worldPosition;
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}
`;
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 <fog_pars_fragment>
#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 <fog_fragment>
}
`;
return {
material: new ShaderMaterial({
vertexShader,
fragmentShader,
uniforms,
transparent: true,
depthWrite: false,
fog: true,
side: BackSide
}),
animationUniform
};
}

View File

@@ -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 <fog_pars_vertex>
void main() {
vUv = uv;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}
`;
const fragmentShader = `
uniform vec3 volumeFogColor;
uniform float volumeFogDensity;
uniform float time;
varying vec2 vUv;
#include <fog_pars_fragment>
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 <fog_fragment>
}
`;
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;
}

View File

@@ -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<string, Mesh<BufferGeometry, Material[]>>();
private volumeTime = 0;
private readonly volumeAnimatedMaterials: ShaderMaterial[] = [];
private readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] = [];
private readonly localLightObjects = new Map<string, Group>();
@@ -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 <fog_pars_vertex>
void main() {
vUv = uv;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}
`;
const fragmentShader = /* glsl */ `
uniform vec3 volumeFogColor;
uniform float volumeFogDensity;
uniform float time;
varying vec2 vUv;
#include <fog_pars_fragment>
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 <fog_fragment>
}
`;
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;
}

View File

@@ -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,

View File

@@ -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,