three-nebula

repository·master·Indexed 22 days ago

https://github.com/creativelifeform/three-nebula

A WebGL-based 3D particle engine designed for three.js. It enables the creation of complex particle effects via programmatic APIs or JSON configurations. Key features include a hierarchical emitter system for nested effects, Ribbon and Trail renderers, and support for simulation determinism. The engine utilizes a system of Initializers and Behaviours to control particle properties like position, mass, radius, and color.

Tokens
18.3K
Snippets
62
Records
98
Agent score
78%

What's inside three-nebula

  1. Overview of three-nebula Modernisation Specs

    master

    The three-nebula library is undergoing a modernisation process organized into specific technical specifications (01–06). These specs cover runtime features, architectural changes, and TypeScript migration.

    Key architectural pillars include:

    • Emitter Hierarchy (01): Support for nested emitters, ribbon/trail renderers, and pooling.
    • Determinism & Scrubbing (02): Ensuring predictable simulation behavior.
    • Sound Renderer (03): Additive capability for audio integration.
    • Schema Versioning (04): Enabling safe transitions between breaking schema changes.
    • Content-Addressed Assets (05): Architectural change for asset management.
    • TypeScript Migration (06): A cross-cutting refactor to improve type safety.
  2. How the Sound Renderer works

    master

    The Sound Renderer treats particles as sound sources. It is a peer renderer to SpriteRenderer, MeshRenderer, RibbonRenderer, and LightRenderer.

    Crucially, particles themselves remain 'dumb'—they only store standard properties like position, life, age, alpha, and size. The renderer is responsible for interpreting the particle stream as audio voices. This allows a single layer to carry multiple renderers; for example, an ember can use both a SpriteRenderer (to glow) and a SoundRenderer (to crackle) without needing duplicate emitters.

    Triggering sounds is handled by the existing emitter controls (rate, burst, delay). To play a sound at a specific offset, use an emitter with a delay value.

  3. Understand Content-Addressed Assets in Three Nebula

    master

    Three Nebula uses content-addressed references instead of inlined base64 strings for assets like textures and audio. This means assets are identified by a unique hash (e.g., sha256:a3f2c1...) rather than being embedded directly in the JSON.

    This approach enables:

    • Deduplication: Multiple systems can reference the same asset hash, saving space.
    • CDN Caching: Assets can be hosted on a CDN and cached individually via their immutable hashes.
    • Asset Identity: You can query which systems use a specific asset by its hash.
    • Performance: Avoids the JSON.parse overhead and main-thread stalls associated with large base64 blobs.
  4. Handle the THREE dependency in TypeScript

    master

    In Three Nebula, three is a peer dependency passed into constructors at runtime (e.g., new SpriteRenderer(container, THREE)).

    When typing these constructors, do not use import * as THREE from 'three' inside your source files, as this will bundle the entire library and break the external dependency model. Instead, use type-only imports:

    • Use typeof import('three') to represent the full Three.js namespace.
    • Alternatively, use a structural interface that only includes the specific subset of Three.js features your component actually requires. This is more consumer-friendly for users on non-standard Three.js builds.
    // Use type-only imports to avoid bundling
    import type { THREE } from 'three';
    
    class BaseRenderer {
      constructor(container: any, THREE: typeof import('three')) { /* ... */ }
    }
  5. Achieve simulation determinism in Three Nebula

    master

    To ensure a simulation is reproducible (same seed + same step count = byte-identical particle state), the simulation must follow a strict determinism contract. This is required for features like reproducible rendering, offline rendering, seek/scrub functionality, and programmatic iteration.

    Core Requirements for Determinism:

    • Seeded PRNG: Replace all Math.random() calls with an injected, seedable generator (e.g., mulberry32 or xoshiro128**). The PRNG instance must be passed down to every initializer, behaviour, and renderer to avoid shared state between multiple systems.
    • Seed Derivation Hierarchy: Randomness must be addressable via a hierarchy so that reordering emitters does not change output. The hierarchy follows:
      • systemSeed (user-set or random at creation)
        • emitterSeed = hash(systemSeed, emitterId)
          • particleSeed = hash(emitterSeed, particleId)
            • childEmitterSeed = hash(systemSeed, childEmitterId, particleId)
    • Fixed Timestep: Decouple simulation advancement from requestAnimationFrame (rAF). Use an accumulator pattern to consume simulation steps in fixed increments (e.g., 1/60s) to ensure identical results across different refresh rates (60Hz vs 144Hz).
    • Stable IDs: Particles must have monotonic, stable IDs that survive pooling (recycled particles must receive new IDs) to prevent iteration order from affecting state.
  6. Understand the Three compatibility shim

    master

    The three-nebula core simulation math relies on specific classes from the three.js library: Vector3 (which serves as the base for Vector3D), Euler, and material blending-mode constants.

    Instead of bundling its own versions of these classes, this module re-exports them directly from the three package installed in your project. This ensures a single source of truth, correct TypeScript types, and mathematical consistency with the specific version of three.js you are using.

  7. How to drive a particle system with system.update()

    master

    A critical requirement for three-nebula is that you must manually drive the particle system from your render loop. Nothing will animate unless you call system.update() once per frame.

    const animate = () => {
      system.update();
      requestAnimationFrame(animate);
    };
    requestAnimationFrame(animate);
  8. Distinguish between Attachment and Events

    master

    When creating effects, distinguish between attaching a child emitter and triggering an event:

    1. Attachment (Hierarchy): A child emitter lives alongside the parent for its entire lifetime (using the inheritance modes described in the hierarchy spec). Use this for trails.
    2. Events: A discrete trigger that spawns a burst which then outlives the parent. Use this for fireworks or explosions.

    Common Event Triggers:

    • onDeath: Spawns a burst at the parent's final position.
    • onCollision: Spawns a burst at the point of contact.
  9. Understand the Emitter Hierarchy and Parent-Child Relationships

    master

    In three-nebula, emitters can be organized into a tree structure rather than a flat list. This allows for effects that ride individual particles (e.g., a spark leaving a smoke trail).

    Key Concepts:

    • Instancing: A child emitter is instantiated once per parent particle at the moment of spawn.
    • Origin: The child instance's origin is the parent particle, not the system.
    • Lifecycle: A child instance is destroyed when its parent particle dies.
    • Orphan Policy: You can control what happens to particles already emitted by a child when the parent dies using the orphanPolicy flag:
      • kill: Child particles die immediately with the parent.
      • detach: Child particles live out their own lifetimes (default behavior).
    • Update Order: Updates are topological (depth-first), ensuring parent particles resolve their transforms before children read them.

    Implementation Note:

    A child instance is treated as a full emitter, not a special particle type. To ensure determinism, child instance seeds are derived from hash(systemSeed, emitterId, parentParticleId).

    {
      "version": 2,
      "emitters": [
        {
          "id": "sparks",
          "rate": { /* ... */ },
          "renderer": { "type": "sprite" },
          "children": [
            {
              "id": "spark-smoke",
              "inherit": { "position": "always", "rotation": "none", "scale": "none" },
              "rate": { /* ... */ },
              "renderer": { "type": "sprite" },
              "children": []
            }
          ]
        }
      ]
    }
  10. Understand the Schema Versioning model

    master

    Three-nebula uses a monotonic integer version field within its JSON systems to manage schema evolution. This allows the library to maintain backward compatibility by running a migration chain whenever a system's version is lower than the current supported version.

    Key Rules:

    • Legacy Support: If the version field is missing, it is treated as version 0. This ensures existing systems created before versioning was implemented remain compatible.
    • Future Versions: If the version in the JSON is higher than the version supported by the current library, the parser must throw a clear, actionable error (e.g., "This system was created with a newer version of three-nebula (v5). This build supports up to v3. Update three-nebula.") rather than crashing or silently failing.
    • Migration: If the version is lower than the current version, the system automatically runs a sequence of migration functions to bring the JSON up to date.
    {
      "version": 2,
      "emitters": [ /* ... */ ]
    }
  11. Handle Asset Migrations (Spec 05 Interaction)

    master

    When migrating schemas that involve asset changes (e.g., moving from inlined base64 textures to hash references in Spec 05), the migration function itself must remain pure.

    Because asset migration requires I/O (writing blobs to a store), the migration should emit an intermediate form that carries the decoded bytes. A separate asset store or consumer is then responsible for handling the actual storage/writing of those bytes. This prevents side effects from leaking into the pure migration chain.

  12. Adhere to the Headless Rendering contract

    master

    To support headless environments (Web Workers, OffscreenCanvas, or headless Chromium), the library must follow a strict contract that separates simulation from the environment.

    Mandatory Constraints:

    • No Internal Loops: The library must not own a loop (no internal requestAnimationFrame). The caller must supply the delta time (dt) and control when to advance.
    • No Wall-Clock Reads: The simulation path must not use Date.now(), performance.now(), or new Date().
    • Separation of Concerns: Simulation and rendering must be separable. A caller must be able to step the simulation $N$ times without rendering, or render the same state multiple times.
    • No DOM Access: The simulation path must not access document, window, or HTMLImageElement. Textures must be provided as decoded data via a resolver.
    • Canvas Agnostic: Any component requiring a canvas must support OffscreenCanvas in addition to HTMLCanvasElement.