// An additive particle field: createPipelineTexture with topology "points" and
// blend "add". Each vertex is one particle; the vertex stage sets
// gl_PointSize (honored across 4..64px) and the fragment stage shapes the
// splat from gl_PointCoord. With blend: "add" overlapping splats accumulate
// (glBlendFunc(ONE, ONE)) - order-independent, so the buffer needs no
// sorting - which is what turns discrete discs into a smooth glowing field.
// Without it a target's draw overwrites, and a point cloud can only thicken
// into scaly overlap.
//
// Additive output is premultiplied by construction: write vec4(color * a, a)
// and the target stays composite-correct in the tree. No depth buffer here -
// nothing occludes anything in a pure additive pass. A scene where opaque
// geometry should occlude the particles would add depth: true and pair the
// blended draw with depthWrite: false, explicitly - neither option implies
// the other.
//
// The tints are typed (vec3) uniforms driven from 3-number array params.
import { render, onFrame, createSignal } from "@solidrt/core"
import { createBuffer, createPipelineTexture, glsl } from "@solidrt/core/gpu"
let VERTEX = glsl`
in vec3 aPos;
in float aSeed;
out float vSeed;
uniform float uTime;
void main() {
float cy = cos(uTime * 0.4), sy = sin(uTime * 0.4);
vec3 p = vec3(cy * aPos.x - sy * aPos.z, aPos.y, sy * aPos.x + cy * aPos.z);
// Each particle breathes on its own phase.
p *= 1.0 + 0.15 * sin(uTime * 1.7 + aSeed * 40.0);
p.z += 2.2;
// Same perspective mapping as gpu-pipeline.tsx (near 1, far 10), clip y
// negated so camera-up displays up.
float f = 2.0;
gl_Position = vec4(p.x * f, -p.y * f, p.z * (11.0 / 9.0) - 20.0 / 9.0, p.z);
gl_PointSize = mix(10.0, 26.0, aSeed) / p.z;
vSeed = aSeed;
}
`
let FRAGMENT = glsl`
in float vSeed;
uniform vec3 uTintA;
uniform vec3 uTintB;
void main() {
// Soft gaussian falloff over the point sprite; gl_PointCoord is 0..1
// across the splat.
vec2 d = gl_PointCoord - 0.5;
float a = exp(-dot(d, d) * 14.0) * 0.35;
vec3 tint = mix(uTintA, uTintB, vSeed);
fragColor = vec4(tint * a, a);
}
`
// Interleaved [pos vec3, seed f32]: points on a fibonacci sphere, so the
// field reads as a volume from every angle.
function particles(count: number): Float32Array {
let verts: number[] = []
let golden = Math.PI * (3.0 - Math.sqrt(5.0))
for (let i = 0; i < count; i++) {
let y = 1.0 - (2.0 * (i + 0.5)) / count
let r = Math.sqrt(1.0 - y * y)
let t = golden * i
let seed = (i * 0.61803399) % 1.0
verts.push(0.7 * r * Math.cos(t), 0.7 * y, 0.7 * r * Math.sin(t), seed)
}
return new Float32Array(verts)
}
function App() {
let bufferId = createBuffer(particles(1500), { label: "particle-verts" })
let id = createPipelineTexture(VERTEX, FRAGMENT, 512, 512, { uTime: 0, uTintA: [1.0, 0.45, 0.15], uTintB: [0.25, 0.5, 1.0] }, {
label: "particles",
attributes: [
{ name: "aPos", format: "vec3" },
{ name: "aSeed", format: "f32" },
],
buffer: bufferId,
topology: "points",
blend: "add",
clearColor: [0.02, 0.02, 0.05, 1],
})
let [time, setTime] = createSignal(0)
onFrame((tick) => setTime(tick / 1000))
return (
)
}
render(() => )