playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
454 lines (375 loc) • 15.1 kB
JavaScript
import { Script, Vec3, BoundingBox, GSplatFormat, GSplatContainer, PIXELFORMAT_RGBA8 } from 'playcanvas';
/**
* @import { Entity } from 'playcanvas'
*/
/**
* Procedural infinite weather particle system using Gaussian splats. Creates a camera-relative
* volume of particles that appear fixed in world space, falling and drifting infinitely.
*
* Each particle occupies a cell in a 3D grid. Its world position is derived from a hash of
* the world-space cell coordinate (baseCell + gridOffset). The entity is snapped to
* baseCell * cellSize so that the static sort centers align with the hashed world positions,
* giving correct depth ordering with both CPU and GPU sorting.
*
* @example
* const weatherEntity = new pc.Entity('Weather');
* weatherEntity.addComponent('script');
* const weather = weatherEntity.script.create(GsplatWeather);
* weather.followEntity = cameraEntity;
* weather.speed = 1.2;
* weather.drift = 0.15;
* app.root.addChild(weatherEntity);
*/
class GsplatWeather extends Script {
static scriptName = 'gsplatWeather';
// --- Grid configuration (read at initialize; call rebuild() after changing) ---
/**
* World-space half-extents of the particle volume (x, y, z). The total volume
* is extents * 2 in each axis, centered on the followed entity.
*
* @type {Vec3}
* @attribute
*/
extents = new Vec3(12, 12, 12);
/**
* Particle density — number of particles per world unit along each axis.
* Higher values produce more particles in the same volume.
*
* @attribute
* @range [0.5, 4]
*/
density = 2;
// --- Runtime properties (updated every frame as uniforms) ---
/**
* Entity to follow. Particle volume always surrounds this entity's position.
* Typically set to the camera entity.
*
* @type {Entity|null}
*/
followEntity = null;
/**
* Fall speed multiplier.
*
* @attribute
* @range [0, 40]
*/
speed = 1.0;
/**
* Per-particle horizontal drift intensity.
*
* @attribute
* @range [0, 1]
*/
drift = 0.15;
/**
* Overall opacity multiplier.
*
* @attribute
* @range [0, 1]
*/
opacity = 0.8;
/**
* Particle color as [r, g, b] in 0..1 range.
*
* @type {number[]}
* @attribute
*/
color = [1, 1, 1];
/**
* Minimum particle size in world units.
*
* @attribute
* @range [0.0001, 0.04]
*/
particleMinSize = 0.006;
/**
* Maximum particle size in world units.
*
* @attribute
* @range [0.0001, 0.04]
*/
particleMaxSize = 0.012;
/**
* Vertical elongation multiplier. 1 = round (snow), higher values stretch
* particles vertically (rain streaks).
*
* @attribute
* @range [1, 20]
*/
elongate = 1.0;
// --- Private state ---
/** @private */
_container = null;
/** @private */
_format = null;
/** @private */
_time = 0;
/** @private */
_baseCellArray = [0, 0, 0];
/** @private */
_camPosArray = [0, 0, 0];
/** @private */
_gridHalfArray = [0, 0, 0];
/** @private */
_particleSizeArray = [0, 0];
/**
* Cell size derived from density. Clamped to avoid division by zero.
*
* @type {number}
* @ignore
*/
get cellSize() {
return 1 / Math.max(this.density, 0.1);
}
/**
* Number of grid half-cells per axis, derived from extents and density.
* Clamped to 1..128 to fit the RGBA8 texture encoding.
*
* @param {number} extent - Half-extent in world units.
* @returns {number} Half-cell count.
* @private
*/
_halfCells(extent) {
return Math.min(128, Math.max(1, Math.floor(extent * Math.max(this.density, 0.1))));
}
/**
* Total number of particles in the grid.
*
* @type {number}
*/
get numParticles() {
const hx = this._halfCells(this.extents.x);
const hy = this._halfCells(this.extents.y);
const hz = this._halfCells(this.extents.z);
return hx * 2 * hy * 2 * hz * 2;
}
initialize() {
this._format = new GSplatFormat(this.app.graphicsDevice, [
{ name: 'data', format: PIXELFORMAT_RGBA8 }
], {
readGLSL: /* glsl */`
uniform float uTime;
uniform float uCellSize;
uniform vec3 uGridHalf;
uniform vec3 uBaseCell;
uniform vec3 uCameraPos;
uniform float uSpeed;
uniform float uDrift;
uniform float uOpacity;
uniform vec3 uColor;
uniform vec2 uParticleSize;
uniform float uElongate;
vec3 weatherLocalPos;
vec3 weatherWC;
vec4 sd;
float weatherHash(vec3 p) {
return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453);
}
vec3 getCenter() {
sd = loadData();
float dx = floor(sd.r * 255.0 + 0.5) - uGridHalf.x;
float dz = floor(sd.b * 255.0 + 0.5) - uGridHalf.z;
float dy = floor(sd.g * 255.0 + 0.5);
vec3 worldCell = uBaseCell + vec3(dx, dy - uGridHalf.y, dz);
weatherWC = worldCell;
vec3 col = vec3(worldCell.x, 0.0, worldCell.z);
float fx = weatherHash(col + vec3(1.0, 0.0, 0.0));
float fz = weatherHash(col + vec3(0.0, 0.0, 3.0));
fx += (weatherHash(worldCell + vec3(4.0, 0.0, 0.0)) - 0.5) * 0.4;
fz += (weatherHash(worldCell + vec3(0.0, 0.0, 5.0)) - 0.5) * 0.4;
float spd = mix(0.3, 0.8, weatherHash(col)) * uSpeed;
float gridYf = uGridHalf.y * 2.0;
float colY = (dy + weatherHash(worldCell + vec3(0.0, 2.0, 0.0))) / gridYf;
colY = fract(colY - uTime * spd / gridYf);
float phase = weatherHash(col + vec3(7.0, 0.0, 0.0)) * 6.28318;
fx += sin(uTime * 0.8 + phase) * uDrift;
fz += cos(uTime * 0.6 + phase) * uDrift;
weatherLocalPos = vec3(
(dx + fx) * uCellSize,
(-uGridHalf.y + colY * gridYf) * uCellSize,
(dz + fz) * uCellSize
);
return weatherLocalPos;
}
vec4 getColor() {
vec3 camOffset = uCameraPos - uBaseCell * uCellSize;
float dist = length(weatherLocalPos - camOffset);
float maxDist = min(uGridHalf.x, uGridHalf.z) * uCellSize * 0.9;
float fade = 1.0 - smoothstep(maxDist * 0.6, maxDist, dist);
float alpha = mix(0.5, 0.9, weatherHash(weatherWC + 20.0)) * fade * uOpacity;
return vec4(uColor, alpha);
}
vec3 getScale() {
float size = mix(uParticleSize.x, uParticleSize.y, weatherHash(weatherWC + 10.0));
return vec3(size, size * uElongate, size);
}
vec4 getRotation() { return vec4(0.0, 0.0, 0.0, 1.0); }
`,
readWGSL: /* wgsl */`
uniform uTime: f32;
uniform uCellSize: f32;
uniform uGridHalf: vec3f;
uniform uBaseCell: vec3f;
uniform uCameraPos: vec3f;
uniform uSpeed: f32;
uniform uDrift: f32;
uniform uOpacity: f32;
uniform uColor: vec3f;
uniform uParticleSize: vec2f;
uniform uElongate: f32;
var<private> weatherLocalPos: vec3f;
var<private> weatherWC: vec3f;
var<private> sd: vec4f;
fn weatherHash(p: vec3f) -> f32 {
return fract(sin(dot(p, vec3f(127.1, 311.7, 74.7))) * 43758.5453);
}
fn getCenter() -> vec3f {
sd = loadData();
let dx = floor(sd.r * 255.0 + 0.5) - uniform.uGridHalf.x;
let dz = floor(sd.b * 255.0 + 0.5) - uniform.uGridHalf.z;
let dy = floor(sd.g * 255.0 + 0.5);
let worldCell = uniform.uBaseCell + vec3f(dx, dy - uniform.uGridHalf.y, dz);
weatherWC = worldCell;
let col = vec3f(worldCell.x, 0.0, worldCell.z);
var fx = weatherHash(col + vec3f(1.0, 0.0, 0.0));
var fz = weatherHash(col + vec3f(0.0, 0.0, 3.0));
fx = fx + (weatherHash(worldCell + vec3f(4.0, 0.0, 0.0)) - 0.5) * 0.4;
fz = fz + (weatherHash(worldCell + vec3f(0.0, 0.0, 5.0)) - 0.5) * 0.4;
let spd = mix(0.3, 0.8, weatherHash(col)) * uniform.uSpeed;
let gridYf = uniform.uGridHalf.y * 2.0;
var colY = (dy + weatherHash(worldCell + vec3f(0.0, 2.0, 0.0))) / gridYf;
colY = fract(colY - uniform.uTime * spd / gridYf);
let phase = weatherHash(col + vec3f(7.0, 0.0, 0.0)) * 6.28318;
fx = fx + sin(uniform.uTime * 0.8 + phase) * uniform.uDrift;
fz = fz + cos(uniform.uTime * 0.6 + phase) * uniform.uDrift;
weatherLocalPos = vec3f(
(dx + fx) * uniform.uCellSize,
(-uniform.uGridHalf.y + colY * gridYf) * uniform.uCellSize,
(dz + fz) * uniform.uCellSize
);
return weatherLocalPos;
}
fn getColor() -> vec4f {
let camOffset = uniform.uCameraPos - uniform.uBaseCell * uniform.uCellSize;
let dist = length(weatherLocalPos - camOffset);
let maxDist = min(uniform.uGridHalf.x, uniform.uGridHalf.z) * uniform.uCellSize * 0.9;
let fade = 1.0 - smoothstep(maxDist * 0.6, maxDist, dist);
let alpha = mix(0.5, 0.9, weatherHash(weatherWC + 20.0)) * fade * uniform.uOpacity;
return vec4f(uniform.uColor, alpha);
}
fn getScale() -> vec3f {
let size = mix(uniform.uParticleSize.x, uniform.uParticleSize.y, weatherHash(weatherWC + 10.0));
return vec3f(size, size * uniform.uElongate, size);
}
fn getRotation() -> vec4f { return vec4f(0.0, 0.0, 0.0, 1.0); }
`
});
this._buildContainer();
}
/**
* Rebuild the particle system. Call after changing grid configuration properties
* (extents, density).
*/
rebuild() {
this._buildContainer();
}
/** @private */
_buildContainer() {
if (this._container) {
this._container.destroy();
this._container = null;
}
const device = this.app.graphicsDevice;
const halfX = this._halfCells(this.extents.x);
const halfY = this._halfCells(this.extents.y);
const halfZ = this._halfCells(this.extents.z);
const gridX = halfX * 2;
const gridY = halfY * 2;
const gridZ = halfZ * 2;
const cs = this.cellSize;
const maxSplats = gridX * gridY * gridZ;
this._container = new GSplatContainer(device, maxSplats, this._format);
const texData = this._container.getTexture('data').lock();
const centers = this._container.centers;
let idx = 0;
for (let x = 0; x < gridX; x++) {
for (let y = 0; y < gridY; y++) {
for (let z = 0; z < gridZ; z++) {
texData[idx * 4 + 0] = x;
texData[idx * 4 + 1] = y;
texData[idx * 4 + 2] = z;
texData[idx * 4 + 3] = Math.random() * 255;
centers[idx * 3 + 0] = (x - halfX + 0.5) * cs;
centers[idx * 3 + 1] = (y - halfY + 0.5) * cs;
centers[idx * 3 + 2] = (z - halfZ + 0.5) * cs;
idx++;
}
}
}
this._container.getTexture('data').unlock();
const halfExtX = halfX * cs;
const halfExtY = halfY * cs;
const halfExtZ = halfZ * cs;
this._container.aabb = new BoundingBox(Vec3.ZERO, new Vec3(halfExtX, halfExtY, halfExtZ));
this._container.update(maxSplats, true);
if (!this.entity.gsplat) {
this.entity.addComponent('gsplat', {
resource: this._container,
unified: true
});
} else {
this.entity.gsplat.resource = this._container;
}
}
update(dt) {
if (!this._container || !this.entity.gsplat) return;
this._time += dt;
const cs = this.cellSize;
let camX = 0, camY = 0, camZ = 0;
if (this.followEntity) {
const pos = this.followEntity.getPosition();
camX = pos.x;
camY = pos.y;
camZ = pos.z;
} else {
const pos = this.entity.getPosition();
camX = pos.x;
camY = pos.y;
camZ = pos.z;
}
const bcX = Math.floor(camX / cs);
const bcY = Math.floor(camY / cs);
const bcZ = Math.floor(camZ / cs);
this.entity.setPosition(bcX * cs, bcY * cs, bcZ * cs);
this._baseCellArray[0] = bcX;
this._baseCellArray[1] = bcY;
this._baseCellArray[2] = bcZ;
this._camPosArray[0] = camX;
this._camPosArray[1] = camY;
this._camPosArray[2] = camZ;
this._gridHalfArray[0] = this._halfCells(this.extents.x);
this._gridHalfArray[1] = this._halfCells(this.extents.y);
this._gridHalfArray[2] = this._halfCells(this.extents.z);
this._particleSizeArray[0] = this.particleMinSize;
this._particleSizeArray[1] = this.particleMaxSize;
const gs = this.entity.gsplat;
gs.setParameter('uTime', this._time);
gs.setParameter('uCellSize', cs);
gs.setParameter('uGridHalf', this._gridHalfArray);
gs.setParameter('uBaseCell', this._baseCellArray);
gs.setParameter('uCameraPos', this._camPosArray);
gs.setParameter('uSpeed', this.speed);
gs.setParameter('uDrift', this.drift);
gs.setParameter('uOpacity', this.opacity);
gs.setParameter('uColor', this.color);
gs.setParameter('uParticleSize', this._particleSizeArray);
gs.setParameter('uElongate', this.elongate);
}
destroy() {
if (this._container) {
this._container.destroy();
this._container = null;
}
}
}
export { GsplatWeather };