Draw particles from their seed and the time since the effect started, so a replay can jump to any moment.
Drag the time back and forth. Each of the 400 sparks is computed from its index and the time alone, so a moment looks the same however you reach it.
A particle system moves its particles forward one step at a time, so a replay can’t jump back to an earlier moment. When each particle’s motion has a closed form, compute it from its seed and the time since the effect started. Then any moment draws the same way, played forward, paused, rewound or jumped to. Write the particle at a moment as a function of its index:
import{
constrandf:{
seed:TgpuFn<(seed:d.F32)=>d.Void>;
seed2:TgpuFn<(seed:d.Vec2f)=>d.Void>;
seed3:TgpuFn<(seed:d.Vec3f)=>d.Void>;
seed4:TgpuFn<(seed:d.Vec4f)=>d.Void>;
sample:TgpuFn<()=>d.F32>;
sampleExclusive:TgpuFn<()=>d.F32>;
normal:TgpuFn<(mu:d.F32,sigma:d.F32)=>d.F32>;
...10more...;
onUnitSphere:TgpuFn<...>;
}
randf}from'@typegpu/noise';
import{
import particles
particles}from'@typegpu/kit';
// Seconds since the blast, which the app writes from its replay clock
const
constsince:TgpuUniform<d.F32>
since=
constroot:TgpuRoot
root.
TgpuRoot.createUniform<d.F32>(typeSchema: d.F32, initial?: number |((buffer:TgpuBuffer<NoInfer<d.F32>>)=>void)|undefined): TgpuUniform<d.F32>(+1 overload)
Allocates memory on the GPU, allows passing data between host and shader.
Read-only on the GPU, optimized for small data. For a general-purpose buffer,
use
TgpuRoot.createBuffer
.
@param ― typeSchema The type of data that this buffer will hold.
@param ― initial Either initial value of the buffer, or an initializer to execute on the mapped buffer. (optional)
createUniform(
import d
d.
constf32:d.F32
export f32
A schema that represents a 32-bit float value. (equivalent to f32 in WGSL)
Can also be called to cast a value to an f32.
@exampleconst value = f32(); // 0
@exampleconst value = f32(1.23); // 1.23
@exampleconst value = f32(true); // 1
f32);
const
constsparkAt:(i:number)=>{
position:d.v3f;
age:number;
velocity:d.v3f;
life:number;
color:d.v4f;
size:number;
seed:number;
}
sparkAt=(
i: number
i:number)=>{
'use gpu';
constrandf:{
seed:TgpuFn<(seed:d.F32)=>d.Void>;
seed2:TgpuFn<(seed:d.Vec2f)=>d.Void>;
seed3:TgpuFn<(seed:d.Vec3f)=>d.Void>;
seed4:TgpuFn<(seed:d.Vec4f)=>d.Void>;
sample:TgpuFn<()=>d.F32>;
sampleExclusive:TgpuFn<()=>d.F32>;
normal:TgpuFn<(mu:d.F32,sigma:d.F32)=>d.F32>;
...10more...;
onUnitSphere:TgpuFn<...>;
}
randf.
seed:(seed:number)=>void
Threads do not share the generator's State.
As a result, unless you change the seed in each thread,
each thread will produce the same sequence.
randf.randSeed sets the private seed of the thread.
@param ― seed seed value to set. For the best results, should be in [-1000, 1000] range.
seed(
import d
d.
functionf32(v?:number|boolean):number
export f32
A schema that represents a 32-bit float value. (equivalent to f32 in WGSL)
Can also be called to cast a value to an f32.
@exampleconst value = f32(); // 0
@exampleconst value = f32(1.23); // 1.23
@exampleconst value = f32(true); // 1
f32(
i: number
i)+1);
const
constvelocity:d.v3f
velocity=
constrandf:{
seed:TgpuFn<(seed:d.F32)=>d.Void>;
seed2:TgpuFn<(seed:d.Vec2f)=>d.Void>;
seed3:TgpuFn<(seed:d.Vec3f)=>d.Void>;
seed4:TgpuFn<(seed:d.Vec4f)=>d.Void>;
sample:TgpuFn<()=>d.F32>;
sampleExclusive:TgpuFn<()=>d.F32>;
normal:TgpuFn<(mu:d.F32,sigma:d.F32)=>d.F32>;
...10more...;
onUnitSphere:TgpuFn<...>;
}
randf.
inUnitSphere:()=>d.v3f
Returns a random 3D vector uniformly distributed inside a unit sphere.
inUnitSphere()*5+
import d
d.
constvec3f:d.Vec3f
(x:number,y:number,z:number)=>d.v3f(+5 overloads)
vec3f(0,3,0);
const
constlife:number
life=0.6+
constrandf:{
seed:TgpuFn<(seed:d.F32)=>d.Void>;
seed2:TgpuFn<(seed:d.Vec2f)=>d.Void>;
seed3:TgpuFn<(seed:d.Vec3f)=>d.Void>;
seed4:TgpuFn<(seed:d.Vec4f)=>d.Void>;
sample:TgpuFn<()=>d.F32>;
sampleExclusive:TgpuFn<()=>d.F32>;
normal:TgpuFn<(mu:d.F32,sigma:d.F32)=>d.F32>;
...10more...;
onUnitSphere:TgpuFn<...>;
}
randf.
sample:()=> number
Returns a random f32 value in [0, 1) range.
sample()*0.6;
const
constage:number
age=
constsince:TgpuUniform<d.F32>
since.
TgpuUniform<F32>.$: number
$;
const
constalive:boolean
alive=
constage:number
age>=0&&
constage:number
age<
constlife:number
life;
return
import particles
particles.
constParticle:d.WgslStruct
(props?:{
position:d.v3f;
age: number;
velocity: d.v3f;
life: number;
color: d.v4f;
size: number;
seed: number;
}|undefined)=>{
position:d.v3f;
age: number;
velocity:d.v3f;
life: number;
color:d.v4f;
size: number;
seed: number;
}
This type describes a particle in shaders.
Particle({
position:d.v3f
position:
constvelocity:d.v3f
velocity*
constage:number
age+
import d
d.
constvec3f:d.Vec3f
(x:number,y:number,z:number)=>d.v3f(+5 overloads)
vec3f(0,-4.9,0)*(
constage:number
age*
constage:number
age),
age: number
Holds particle age in seconds.
age,
velocity:d.v3f
velocity,
life: number
Sets lifetime in seconds. A particle is alive while age < life.
Builds a pipeline for drawing that writes every described output and returns its draw
methods. If the drawing cannot supply the fields an output needs, type checking fails with
that output's name. Use without to leave it out or instead to replace it.
Controls depth testing and writing when the description has a depth target. Default { depthWriteEnabled: true, depthCompare: 'less' }, with the description's bias.
depth:{
depthWriteEnabled?: boolean |undefined
Indicates if this
GPURenderPipeline
can modify
GPURenderPassDescriptor#depthStencilAttachment
depth values.
depthWriteEnabled:false,
depthCompare?: GPUCompareFunction |undefined
The comparison operation used to test fragment depths against
Records a draw into the pass, on which the description has set the pipeline it built. That
pipeline binds indices if supplied. drawIndexed throws without an index buffer. A group
the drawing sets with pass.setBindGroup stays for later drawings in the pass that read its
layout, so give such a layout to one drawing.
Runs the loop on each scheduled frame. Time advances by at most maxDt per frame, never steps
back, and stops while the surface is paused. Destroying the animation keeps the loop alive.
Another animation on the same loop continues from its recorded time. A frame error stops the
animation.
@param ― loop the loop to run on each unpaused frame. Continues from its seconds if it ran before
@returns ― an animation you destroy to stop the frames
Begins a pass, draws drawn or each drawing in a list in order, then ends the pass. Reuse the
list to avoid allocating each frame. Call begin yourself when you need to choose drawings
per frame. Records nothing while enabled is false.
record(
frame:frames.Frame
frame,
constdrawn:passes.Drawn
drawn);
}),
);
Nothing is stored between frames. The seed fixes each spark’s velocity, life and look, and since fixes where it is.
A spark outside its life takes size 0, so its quad covers no pixels. Every spark still runs its vertices each frame.
Each pixel computes its spark again from the seed. Keep the per-spark work small.
The motion needs a closed form, such as v t + g t² / 2 under gravity. Collisions, update callbacks and anything that reads the last step need a particle system.
For several blasts, draw COUNT instances per blast and read each blast’s origin and start time from a buffer at instanceIndex / COUNT.