raindrop-fx
Version:
Rain drop effect with WebGL
266 lines (244 loc) • 8.2 kB
text/typescript
import { minus, Rect, vec2 } from "@sardinefish/zogra-renderer";
import { RainDrop } from "./raindrop";
import { JitterOption } from "./random";
import { Spawner } from "./spawner";
import { Time } from "./utils";
export interface SimulatorOptions
{
viewport: Rect;
/**
* Time range between two raindrop spwan.
*/
spawnInterval: [number, number];
/**
* Random size range when spawn a new raindrop
*/
spawnSize: [number, number];
/**
* Maximal amount of spawned raindrops.
* Recommend less than 2000
*/
spawnLimit: number;
/**
* Recommend in range (0..1), other value should be ok.
*/
slipRate: number;
/**
* Describe how often a raindrop change its motion state
*/
motionInterval: [number, number];
/**
* Random velociy x relative to velocity y. Recommend in range (0..0.1)
*/
xShifting: [number, number];
/**
* Relative size for collision check. Recommend in range (0.6..1.2)
*/
colliderSize: number;
/**
* Mass density of the slipping trail raindrop.
* Recommend in range (0.1..0.3)
*
* A large value cause a raindrop quickly lose its size during slipping.
*/
trailDropDensity: number;
/**
* Random size range of slipping trail drop. Recommend in range (0.3..0.5)
*/
trailDropSize: [number, number];
/**
* Random distance range between two slipping trail drop. Recommend in range (20..40)
*/
trailDistance: [number, number];
/**
* Vertical spread of a new spawned slipping trail drop. Recommend in range (0.4..0.6)
*/
trailSpread: number
/**
* Spread rate when a new spawned raindrop hit the screen. Recommend in range (0.4..0.7)
*/
initialSpread: number;
/**
* Spread shrink rate per seconds. Recommend in range (0.01..0.02)
*/
shrinkRate: number;
/**
* Spread rate by velocity Y. Recommend in range (0.2..0.4)
*
* Raindrop with higher fall speed looks more narrow.
*/
velocitySpread: number;
/**
* Mass lose per second. Recommend in range (10..30)
*/
evaporate: number;
/**
* Gravity acceleration in pixels/s. Recommend 2400
*/
gravity: number;
}
export class CollisionGrid extends Array<RainDrop>
{
/**@deprecated */
push(...item :RainDrop[])
{
return super.push(...item);
}
add(raindrop: RainDrop)
{
const len = super.push(raindrop);
raindrop.gridIdx = len - 1;
raindrop.grid = this;
}
delete(raindrop: RainDrop)
{
this[raindrop.gridIdx as number] = this[this.length - 1];
this[raindrop.gridIdx as number].gridIdx = raindrop.gridIdx;
this.length--;
raindrop.gridIdx = -1;
raindrop.grid = undefined;
}
}
export class RaindropSimulator
{
options: SimulatorOptions;
spawner: Spawner;
raindrops: RainDrop[] = [];
grid: CollisionGrid[] = [];
constructor(options: SimulatorOptions)
{
this.options = options;
this.spawner = new Spawner(this, options);
this.resize();
}
get gridSize() { return this.options.spawnSize[1] * 0.3 } // max collide distance
resize()
{
const w = Math.ceil(this.options.viewport.size.x / this.gridSize);
const h = Math.ceil(this.options.viewport.size.y / this.gridSize);
let base = 0;
if (this.grid.length < w * h)
{
base = this.grid.length;
this.grid.length = w * h;
}
for (let i = base; i < this.grid.length; i++)
this.grid[i] = new CollisionGrid();
}
gridAt(gridX: number, gridY: number)
{
if (gridX < 0 || gridY < 0)
return undefined;
const gridWidth = Math.ceil((this.options.viewport.xMax - this.options.viewport.xMin) / this.gridSize);
const idx = gridY * gridWidth + gridX;
if (idx >= this.grid.length)
return undefined;
return this.grid[idx];
}
gridAtWorldPos(x: number, y: number)
{
return this.gridAt(...this.worldToGrid(x, y));
}
worldToGrid(x: number, y: number): [number, number]
{
const gridX = Math.floor(x / this.gridSize);
const gridY = Math.floor(y / this.gridSize);
return [gridX, gridY];
}
add(raindrop: RainDrop)
{
this.raindrops.push(raindrop);
let grid = this.gridAtWorldPos(raindrop.pos.x, raindrop.pos.y);
if (grid)
{
grid.add(raindrop);
raindrop.gridIdx = grid.length - 1;
}
}
update(time: Time)
{
if (this.raindrops.length <= this.options.spawnLimit)
{
for (const newDrop of this.spawner.update(time.dt).trySpawn())
{
this.raindrops.push(newDrop);
}
}
this.raindropUpdate(time);
this.collisionUpdate();
for (let i = 0; i < this.raindrops.length; i++)
{
if (this.raindrops[i].destroied)
{
this.raindrops[i].grid?.delete(this.raindrops[i]);
this.raindrops[i] = this.raindrops[this.raindrops.length - 1];
this.raindrops.length--;
}
}
}
raindropUpdate(time: Time)
{
for (let i = 0; i < this.raindrops.length; i++)
{
const raindrop = this.raindrops[i];
if (raindrop.destroied)
continue;
raindrop.updateRaindrop(time);
if (raindrop.pos.y < -100)
raindrop.destroied = true;
if (raindrop.destroied)
continue;
const [gridX, gridY] = this.worldToGrid(raindrop.pos.x, raindrop.pos.y);
const grid = this.gridAt(gridX, gridY);
if (grid !== raindrop.grid)
{
raindrop.grid?.delete(raindrop);
grid?.add(raindrop);
raindrop.grid = grid;
}
}
}
collisionUpdate()
{
for (let i = 0; i < this.raindrops.length; i++)
{
const raindrop = this.raindrops[i];
if (raindrop.destroied)
continue;
const [gridX, gridY] = this.worldToGrid(raindrop.pos.x, raindrop.pos.y);
for (let x = -1; x <= 1; x++)
{
for (let y = -1; y <= 1; y++)
{
const grid = this.gridAt(gridX + x, gridY + y);
if (!grid)
continue;
for (const other of grid)
{
const isSame = other === raindrop;
const isParent = other.parent === raindrop || raindrop.parent === other;
const isAdjacent = raindrop.parent && (raindrop.parent === other.parent);
if (other.destroied || isParent || isAdjacent || isSame)
continue;
let dx = raindrop.pos.x - other.pos.x;
let dy = raindrop.pos.y - other.pos.y;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance - raindrop.mergeDistance - other.mergeDistance < 0)
{
if (raindrop.mass >= other.mass)
{
raindrop.merge(other);
other.destroied = true;
}
else
{
other.merge(raindrop);
raindrop.destroied = true;
}
}
}
}
}
}
}
}