GPU trails, animated sources and CS contacts — engine23 Use Chrome WebGPU. Test getGPUCapabilities(): gpuTrails, gpuFlipbooks, gpuEmitterSources, gpuEmitterSceneContacts. Refresh Game after a player update. All commands return promises. Await creation before commands using its result. Existing 64-byte particle ABI, renderer and wind commands remain compatible. TRAILS Draw persistent curved ribbons behind a GPU collection, without CPU position reads. createGPUTrails("sparks", {count:64, points:64, lifetime:1.5, width:.04, endWidth:0}) resetGPUTrails("sparks") getGPUTrailState("sparks") removeGPUTrails("sparks") Settings: first0/stride1 select source instances; count defaults min(population,64). 1..4096 trails, 2..256 history points, maximum262144 points total. History samples at lifetime/(points-1), no more than once per render. Low FPS gives fewer samples. Width/endWidth in world units; lifetime seconds; color/endColor RGB vectors. additive:true, teleport:5 meters. Teleport, birth change and reset break continuity. Particle properties.w must be a stable birth ID/time until recycled. Dead particles (positionSize.w<=0 or color.a<=0) stop contributing. Their existing history fades. Trails are visual ribbons, not physical ropes. Render bounds use collection extent. Follow an ordinary CS object's transform. A dedicated one-instance collection is created and hidden; only its trail is drawn. Does not change the object's physics. createObjectTrail("handTrail", "objectname", {offset:{x:.5,y:0,z:0},width:.06}) removeGPUInstances("handTrail") Object must already exist. Its removal clears the trail. Object trails and emitters exclusively own their collection; do not run custom WGSL on those buffers. Ordinary GPU trails can observe a custom WGSL collection. Only a single object position is uploaded for an object trail. Per-particle history/rendering remains GPU-side. ANIMATED APPEARANCE Use a PNG atlas, tiles ordered left-to-right, TOP row first. styleGPUInstances("pulses", {lit:false,surface:"additive",castShadows:false, atlasColumns:4,atlasRows:4,atlasFrames:16,atlasBlend:true,softDepth:.3}) setGPUInstanceTexture("pulses", "albedo", "https://example.com/pulse-atlas.png") Atlas columns/rows1..16; frames must fit. atlasFPS0 uses normalized particle age (properties.x / properties.y). Positive FPS uses age seconds. atlasLoop:false clamps to last frame; true repeats. atlasBlend:true interpolates adjacent frames. Only albedo is atlased. Normal and metallic/roughness maps retain normal UVs. softEdge0 disables radial edge fading; 1 is a useful soft sprite. softDepth0 disables intersection fading; positive world distance fades against opaque camera depth. Use transparent/additive surfaces for fading. Depth fade doesn't collide particles and doesn't sample transparent surfaces. It enables camera depth where needed. No extra shader keyword permutations for these features. EMISSION SOURCES Existing createGPUEmitter/setGPUEmitter now accept box, path or mesh sources. Pause before setGPUEmitter; setting resets the seeded emitter. Capacity must be at least rate*lifeMax. Existing particles move independently after being emitted. Emit uniformly by path length (2..1024 points). setGPUEmitter("sparks", {emissionShape:"path",path:[{x:-2,y:0,z:0},{x:2,y:2,z:0}]}) Emit uniformly by world-space triangle area. Uses the first readable MeshFilter's first submesh, up to8192 triangles. Static meshes only; skinned emission unsupported. setGPUEmitter("sparks", {emissionShape:"mesh",meshObject:"objectname",surfaceNormals:true}) A mesh source automatically follows its mesh transform. surfaceNormals selects outward triangle normals as initial emission directions. Other sources use direction. For box/path, followObject attaches source coordinates to an ordinary CS object. setGPUEmitter("sparks", {emissionShape:"box",followObject:"objectname",position:{x:0,y:1,z:0}}) moveGPUEmitter("sparks", 0, 2, 0) moveGPUEmitter updates only the source offset live; no reset. Local coordinates when attached, world coordinates otherwise. Changing geometry-source scale requires reapplying settings to rebuild length/area weights. Removing an attachment stops its emitter with a readable state.error. No per-particle CPU sampling. SCENE CONTACTS Default emitters remain visual and support larger populations. Opt into collisions for up to8192 slots per emitter. Collisions reuse the existing GPU physics kernels; this is visual particle-to-scene contact, NOT inter-particle fluid/sand simulation. pauseGPUEmitter("sparks") setGPUEmitterCollisions("sparks", {radius:.025,bounce:.5,friction:.3}) addGPUEmitterCollider("sparks", "objectname") startGPUEmitter("sparks") Default one-way: bounce/friction on GPU; no collision readback and no forces on Unity. Body mode supports ordinary box/sphere/capsule colliders; dynamic and kinematic motion is included. Static MeshCollider/TerrainCollider uses a captured GPU BVH: addGPUEmitterCollider("sparks", "terrainname", "scenery") refreshGPUEmitterCollider("sparks", "terrainname") removeGPUEmitterCollider("sparks", "objectname") Refresh scenery after height/mesh/transform changes. Dynamic mesh colliders are not supported. Up to16 registered collider bodies, existing64 sample budget. Disable emitter floorEnabled when registering the same floor to avoid solving it twice. Collision radius is independent of rendered sprite size. Optional two-way forces and bounded impact reports: setGPUEmitterCollisions("sparks", {twoWay:true,events:true,mass:.001, radius:.025,bounce:.5,friction:.3,maxImpulse:.1,maxTorque:.1}) getGPUEmitterContacts("sparks", 32) getGPUEmitterState("sparks") Configure while paused. Disable contacts first before replacing configuration: setGPUEmitterCollisions("sparks", {enabled:false}) Mass is kg/slot. For two-way coupling capacity*mass must not exceed1/4 of each participating dynamic rigidbody's mass. Aggregated force/torque is returned by a small asynchronous readback and applied once in Unity FixedUpdate. maxImpulse and maxTorque bound a returned batch (N*s / N*m*s); they are safety caps, not exact conservation guarantees. Additional GPU steps wait while feedback is in flight; state.droppedSteps exposes skipped catch-up work. Benchmark in the target browser. Pausing/reset/removal invalidates pending feedback. No synchronous browser readback. Events report aggregated contacts per collider, not every grain/triangle collision. getGPUEmitterState returns emitter + emitterPhysics (latency, pending, drops, bodies, triangle tests, readback bytes). events queue is bounded256; reads drain it. Default one-way avoids these feedback costs. These are game-effects capabilities, not a replacement for rigidbody physics or a high-density two-way fluid solver. COORDINATES GPU position/velocity vectors use Unity world coordinates, Y up. The legacy CS object.pos.x/shared-memory bridge negates Unity X. Do not compare those X values as if they were identical. Named-object attachment/collider commands resolve the Unity transform internally; no manual conversion is needed for those commands. RECIPES / DEMOS science-recipes/gpu-effects-trails.js — light ribbons and ordinary CS object trail. science-recipes/gpu-effects-sources.js — animated pulses from mesh/path sources. science-recipes/gpu-effects-contacts.js — sparks deflect and push a CS rigidbody. Use standard modern responsive UI with camera/reset/sound controls. Show contacts and actual target motion before claiming two-way interaction. Keep object pose changes to setup/reset or explicit user actions; physics controls motion during play. Verification: cs-science-framework/roadmap-batch13-15/tests/unity-results.txt. Native numerical/render checks complete; browser results recorded after deployment.