airframe.io / src /game /cloudField.ts
LATTICEWORKS's picture
Fix stall physics, tracer visibility, net packet size, and type modularization
ea061d3
Raw
History Blame Contribute Delete
17.1 kB
import * as THREE from "three";
import { NodeMaterial } from "three/webgpu";
import {
Break,
Fn,
If,
Loop,
attribute,
cameraPosition,
clamp,
dot,
exp,
float,
fract,
length,
max,
min,
mix,
normalize,
positionWorld,
pow,
select,
smoothstep,
texture3D,
uniform,
vec2,
vec3,
vec4
} from "three/tsl";
import {
MapDefinition,
getAtmosphereSunDirection
} from "./content/maps/mapTypes";
type CloudVolume = {
center: THREE.Vector3;
radius: THREE.Vector3;
density: number;
};
const NOISE_SIZE = 64;
const RAY_STEPS = 42;
function hashString(value: string): number {
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function createRandom(seed: number) {
let state = seed >>> 0;
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
function latticeHash(x: number, y: number, z: number, seed: number) {
let value = Math.imul(x, 374761393);
value = Math.imul(value ^ Math.imul(y, 668265263), 1274126177);
value = Math.imul(value ^ Math.imul(z, 2246822519), 3266489917);
value ^= seed;
value ^= value >>> 15;
value = Math.imul(value, 2246822519);
value ^= value >>> 13;
return (value >>> 0) / 4294967295;
}
function fade(value: number) {
return value * value * value * (value * (value * 6 - 15) + 10);
}
function lerp(a: number, b: number, amount: number) {
return a + (b - a) * amount;
}
function periodicValueNoise(
x: number,
y: number,
z: number,
period: number,
seed: number
) {
const x0 = Math.floor(x);
const y0 = Math.floor(y);
const z0 = Math.floor(z);
const tx = fade(x - x0);
const ty = fade(y - y0);
const tz = fade(z - z0);
const wrap = (value: number) => ((value % period) + period) % period;
const sample = (dx: number, dy: number, dz: number) =>
latticeHash(
wrap(x0 + dx),
wrap(y0 + dy),
wrap(z0 + dz),
seed
);
const x00 = lerp(sample(0, 0, 0), sample(1, 0, 0), tx);
const x10 = lerp(sample(0, 1, 0), sample(1, 1, 0), tx);
const x01 = lerp(sample(0, 0, 1), sample(1, 0, 1), tx);
const x11 = lerp(sample(0, 1, 1), sample(1, 1, 1), tx);
return lerp(lerp(x00, x10, ty), lerp(x01, x11, ty), tz);
}
function fbm(
nx: number,
ny: number,
nz: number,
firstPeriod: number,
octaves: number,
seed: number
) {
let value = 0;
let amplitude = 0.56;
let normalization = 0;
let period = firstPeriod;
for (let octave = 0; octave < octaves; octave++) {
value += periodicValueNoise(
nx * period,
ny * period,
nz * period,
period,
seed + octave * 1013
) * amplitude;
normalization += amplitude;
amplitude *= 0.5;
period *= 2;
}
return value / normalization;
}
function createCloudNoiseTexture(seed: number) {
const data = new Uint8Array(NOISE_SIZE ** 3 * 4);
let offset = 0;
for (let z = 0; z < NOISE_SIZE; z++) {
const nz = z / NOISE_SIZE;
for (let y = 0; y < NOISE_SIZE; y++) {
const ny = y / NOISE_SIZE;
for (let x = 0; x < NOISE_SIZE; x++) {
const nx = x / NOISE_SIZE;
const base = fbm(nx, ny, nz, 4, 4, seed);
const detail = fbm(nx, ny, nz, 12, 3, seed ^ 0x9e3779b9);
data[offset] = Math.round(THREE.MathUtils.clamp(base, 0, 1) * 255);
data[offset + 1] = Math.round(
THREE.MathUtils.clamp(detail, 0, 1) * 255
);
data[offset + 2] = 0;
data[offset + 3] = 255;
offset += 4;
}
}
}
const texture = new THREE.Data3DTexture(
data,
NOISE_SIZE,
NOISE_SIZE,
NOISE_SIZE
);
texture.name = "cloud-density-noise";
texture.format = THREE.RGBAFormat;
texture.type = THREE.UnsignedByteType;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.wrapR = THREE.RepeatWrapping;
texture.unpackAlignment = 1;
texture.generateMipmaps = false;
texture.needsUpdate = true;
return texture;
}
const hitUnitBox = Fn(([origin, direction]: [any, any]) => {
// Prevent division by zero when direction components are exactly 0.0,
// which causes slab intersections to yield -Infinity bounds.
const safeDirection = vec3(
select(direction.x.equal(0.0), float(1e-7), direction.x),
select(direction.y.equal(0.0), float(1e-7), direction.y),
select(direction.z.equal(0.0), float(1e-7), direction.z)
);
const inverseDirection = (safeDirection as any).reciprocal();
const tMinTemporary = vec3(-0.5).sub(origin).mul(inverseDirection);
const tMaxTemporary = vec3(0.5).sub(origin).mul(inverseDirection);
const tMin = min(tMinTemporary, tMaxTemporary);
const tMax = max(tMinTemporary, tMaxTemporary);
return vec2(
max(tMin.x, max(tMin.y, tMin.z)),
min(tMax.x, min(tMax.y, tMax.z))
);
});
export class CloudField {
public readonly mesh: THREE.InstancedMesh;
private readonly material: NodeMaterial;
private readonly noiseTexture: THREE.Data3DTexture;
private readonly volumes: CloudVolume[] = [];
private readonly uTime = uniform(0);
private readonly uFogNearRef: ReturnType<typeof uniform>;
private readonly uFogFarRef: ReturnType<typeof uniform>;
constructor(def: MapDefinition) {
const mapSeed = hashString(def.id);
const random = createRandom(mapSeed);
const atmosphere = def.atmosphere;
const cloudProfile = atmosphere.cloudField;
const clusterCount = Math.round(
cloudProfile.clusterBase +
def.cloudDensity * cloudProfile.clusterDensityScale
);
const centerArray = new Float32Array(clusterCount * 3);
const sizeArray = new Float32Array(clusterCount * 3);
const seedArray = new Float32Array(clusterCount);
const densityArray = new Float32Array(clusterCount);
const worldRadius = def.world.radius;
for (let index = 0; index < clusterCount; index++) {
const angle = random() * Math.PI * 2;
// Distribute clusters across the full world radius so clouds aren't
// all piled at the map centre when the radius is large (e.g. 32 000 m).
const minDist = worldRadius * 0.04;
const maxDist = worldRadius * 0.88;
const distance = minDist + Math.sqrt(random()) * (maxDist - minDist);
const center = new THREE.Vector3(
Math.cos(angle) * distance,
THREE.MathUtils.lerp(
cloudProfile.altitudeMin,
cloudProfile.altitudeMax,
random()
),
Math.sin(angle) * distance
);
const radius = new THREE.Vector3(
440 + random() * 620,
150 + random() * 220,
400 + random() * 580
);
const density = 0.68 + random() * 0.28;
this.volumes.push({ center, radius, density });
center.toArray(centerArray, index * 3);
radius.clone().multiplyScalar(2).toArray(sizeArray, index * 3);
seedArray[index] = random();
densityArray[index] = density;
}
const geometry = new THREE.BoxGeometry(1, 1, 1);
geometry.setAttribute(
"aCloudCenter",
new THREE.InstancedBufferAttribute(centerArray, 3)
);
geometry.setAttribute(
"aCloudSize",
new THREE.InstancedBufferAttribute(sizeArray, 3)
);
geometry.setAttribute(
"aCloudSeed",
new THREE.InstancedBufferAttribute(seedArray, 1)
);
geometry.setAttribute(
"aCloudDensity",
new THREE.InstancedBufferAttribute(densityArray, 1)
);
const cloudCenter = attribute("aCloudCenter", "vec3") as any;
const cloudSize = attribute("aCloudSize", "vec3") as any;
const cloudSeed = attribute("aCloudSeed", "float") as any;
const cloudDensity = attribute("aCloudDensity", "float") as any;
const localVertex = attribute("position", "vec3") as any;
this.noiseTexture = createCloudNoiseTexture(mapSeed);
const noiseVolume = texture3D(this.noiseTexture, null, 0);
const uBright = uniform(new THREE.Color(cloudProfile.brightColor)) as any;
const uShadow = uniform(new THREE.Color(cloudProfile.shadowColor)) as any;
const uFogColor = uniform(new THREE.Color(atmosphere.fogColor)) as any;
const uFogNear = uniform(cloudProfile.fogNear);
const uFogFar = uniform(cloudProfile.fogFar);
this.uFogNearRef = uFogNear;
this.uFogFarRef = uFogFar;
const uCoverage = uniform(
THREE.MathUtils.clamp(def.cloudDensity, 0, 1)
);
const uSunDirection = uniform(
getAtmosphereSunDirection(atmosphere)
) as any;
const uTime = this.uTime;
const sampleDensity: any = Fn(([localPosition]: [any]) => {
const uvw = localPosition.add(0.5).toVar();
const horizontalEdge = min(
min(uvw.x, float(1).sub(uvw.x)),
min(uvw.z, float(1).sub(uvw.z))
);
const edgeEnvelope = smoothstep(0.0, 0.14, horizontalEdge);
const baseEnvelope = smoothstep(0.0, 0.12, uvw.y);
const topEnvelope = float(1).sub(smoothstep(0.62, 1.0, uvw.y));
const heightEnvelope = baseEnvelope.mul(topEnvelope);
const wind = vec3(
uTime.mul(0.0018),
float(0),
uTime.mul(0.0007)
);
const seedOffset = vec3(
cloudSeed.mul(0.173),
cloudSeed.mul(0.317),
cloudSeed.mul(0.271)
);
const baseSample = noiseVolume.sample(
fract(uvw.mul(vec3(1.45, 1.08, 1.45)).add(seedOffset).add(wind))
);
const detailSample = noiseVolume.sample(
fract(
uvw
.mul(vec3(3.4, 2.2, 3.4))
.add(seedOffset.mul(2.7))
.sub(wind.mul(1.8))
)
);
const shapeNoise = baseSample.r
.mul(0.78)
.add(baseSample.g.mul(0.22));
const erosion = detailSample.g.mul(0.17);
const coverageBias = mix(-0.03, 0.14, uCoverage);
const signal = shapeNoise
.add(coverageBias)
.sub(erosion)
.sub(float(1).sub(edgeEnvelope.mul(heightEnvelope)).mul(0.72));
return smoothstep(0.34, 0.56, signal).mul(cloudDensity);
});
const worldPosition = Fn(() =>
cloudCenter.add(localVertex.mul(cloudSize))
)();
const fragmentNode = Fn(() => {
const worldRayDirection = normalize(
positionWorld.sub(cameraPosition)
).toVar("worldRayDirection");
const localRayOrigin = cameraPosition
.sub(cloudCenter)
.div(cloudSize)
.toVar("localRayOrigin");
const localRayDirection = normalize(
worldRayDirection.div(cloudSize)
).toVar("localRayDirection");
const bounds = hitUnitBox(
localRayOrigin,
localRayDirection
).toVar("cloudBounds");
bounds.x.greaterThan(bounds.y).discard();
bounds.x.assign(max(bounds.x, 0));
const inverseDirection = (localRayDirection as any)
.abs()
.reciprocal() as any;
const stepLength = min(
inverseDirection.x,
min(inverseDirection.y, inverseDirection.z)
).div(RAY_STEPS);
const localStep = localRayDirection.mul(stepLength);
const worldStepLength = length(localStep.mul(cloudSize));
const rayPosition = localRayOrigin
.add(localRayDirection.mul(bounds.x))
.toVar("cloudRayPosition");
const jitter = fract(
dot(localVertex.xz.add(cloudSeed), vec2(12.9898, 78.233))
.sin()
.mul(43758.5453)
);
rayPosition.addAssign(localStep.mul(jitter));
const accumulated = vec4(0).toVar("cloudAccumulation");
const localSunDirection = normalize(
uSunDirection.div(cloudSize)
);
const viewSunCosine = dot(
worldRayDirection.negate(),
uSunDirection
);
const forwardScatter = pow(
clamp(viewSunCosine.mul(0.5).add(0.5), 0, 1),
6
);
Loop(
{
type: "float",
start: bounds.x,
end: bounds.y,
update: stepLength
},
() => {
const density = float(
sampleDensity(rayPosition) as any
).toVar("sampleDensity");
If(density.greaterThan(0.002), () => {
const lightDensity = float(
sampleDensity(
rayPosition.add(localSunDirection.mul(0.055))
) as any
)
.mul(0.55)
.add(
float(
sampleDensity(
rayPosition.add(localSunDirection.mul(0.13))
) as any
).mul(0.3)
)
.add(
float(
sampleDensity(
rayPosition.add(localSunDirection.mul(0.25))
) as any
).mul(0.15)
);
const lightTransmission = exp(lightDensity.mul(-2.35));
const lightAmount = clamp(
float(0.16)
.add(lightTransmission.mul(0.72))
.add(forwardScatter.mul(0.2)),
0,
1
);
const sampleColor = mix(uShadow, uBright, lightAmount);
const sampleAlpha = float(1).sub(
exp(density.mul(worldStepLength).mul(-0.0085))
);
const remaining = accumulated.a.oneMinus();
accumulated.rgb.addAssign(
sampleColor.mul(sampleAlpha).mul(remaining)
);
accumulated.a.addAssign(sampleAlpha.mul(remaining));
});
If(accumulated.a.greaterThanEqual(0.97), () => {
Break();
});
rayPosition.addAssign(localStep);
}
);
const entryPosition = cloudCenter.add(
localRayOrigin
.add(localRayDirection.mul(bounds.x))
.mul(cloudSize)
);
const fogFactor = smoothstep(
uFogNear,
uFogFar,
length(entryPosition.sub(cameraPosition))
);
accumulated.rgb.assign(
mix(accumulated.rgb, uFogColor.mul(accumulated.a), fogFactor)
);
// Fully fade out the cloud opacity at fog.far (fogFactor = 1.0)
// to prevent harsh clipping boundaries at extreme viewing angles.
accumulated.a.mulAssign(float(1).sub(fogFactor.mul(1.0)));
return accumulated;
})();
this.material = new NodeMaterial();
this.material.positionNode = worldPosition;
this.material.fragmentNode = fragmentNode;
this.material.side = THREE.BackSide;
this.material.transparent = true;
this.material.depthWrite = false;
this.material.depthTest = true;
// Define large custom bounding volumes to prevent incorrect frustum culling.
// Since clouds are positioned in the vertex shader using a custom attribute
// while the mesh remains at the origin (0,0,0) with an identity matrix, the
// default 1x1x1 bounding volume would be culled when the origin is off-screen.
const customBoundsRadius = worldRadius * 2;
geometry.boundingBox = new THREE.Box3(
new THREE.Vector3(-customBoundsRadius, -customBoundsRadius, -customBoundsRadius),
new THREE.Vector3(customBoundsRadius, customBoundsRadius, customBoundsRadius)
);
geometry.boundingSphere = new THREE.Sphere(
new THREE.Vector3(0, 0, 0),
customBoundsRadius
);
this.mesh = new THREE.InstancedMesh(
geometry,
this.material as unknown as THREE.Material,
clusterCount
);
this.mesh.name = "raymarched-cloud-field";
this.mesh.frustumCulled = false;
this.mesh.renderOrder = 10;
this.mesh.boundingBox = geometry.boundingBox.clone();
this.mesh.boundingSphere = geometry.boundingSphere.clone();
this.mesh.computeBoundingBox = () => {};
this.mesh.computeBoundingSphere = () => {};
const identity = new THREE.Matrix4();
for (let index = 0; index < clusterCount; index++) {
this.mesh.setMatrixAt(index, identity);
}
this.mesh.instanceMatrix.needsUpdate = true;
}
public update(dt: number) {
this.uTime.value += THREE.MathUtils.clamp(dt, 0, 0.05);
}
public updateFog(near: number, far: number) {
this.uFogNearRef.value = near;
this.uFogFarRef.value = far;
}
public sampleDensity(position: THREE.Vector3): number {
let total = 0;
for (const volume of this.volumes) {
const dx = (position.x - volume.center.x) / volume.radius.x;
const dy = (position.y - volume.center.y) / volume.radius.y;
const dz = (position.z - volume.center.z) / volume.radius.z;
const normalizedDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (normalizedDistance < 1) {
total = Math.max(
total,
(1 - THREE.MathUtils.smoothstep(normalizedDistance, 0.32, 1)) *
volume.density
);
}
}
return THREE.MathUtils.clamp(total, 0, 1);
}
public dispose() {
this.mesh.geometry.dispose();
this.material.dispose();
this.noiseTexture.dispose();
}
}