acron.splats

Gaussian splats on Acron: a SOG bundle’s planes as textures, a work-buffer pass that decodes them (and lets the application deform them), one instanced draw in back-to-front order, and the sorter that produces that order.

The design follows the PlayCanvas engine’s gsplat pipeline — planes kept packed on the GPU, a “work buffer” recomputed per view change, quads batched per instance, a sorter on a worker — which is the reference for rendering scans of this size in real time. What the application supplies is a SplatModifier: a GLSL chunk that may move each splat and reshape its covariance (a curved-world navigation view, a fisheye, an explosion — the identity is the default) and the per-frame view. Everything else is here.

The pieces (each its own module; the package re-exports the API):

  • SplatSource (source) — a SogBundle’s five RGBA8 planes (from a .sog or a 3DGS .ply, see acron.sog.read_splats()), its codebook and its SH palette as nearest-filtered textures, uploaded once, undecoded (135 MB for a 6.8 M-splat scan without SH; nothing is decoded on the CPU; the planes go from the native bundle to the textures without passing through Python).

  • WorkBuffer (work) — ONE render-to-texture pass over one texel per splat (a card the size of the planes, an orthographic camera, five RGBA32F targets via set_aux_float) that decodes the planes and runs the modifier, writing the centre, the two halves of the covariance and the colour — with the bundle’s spherical-harmonic bands evaluated for the update’s camera direction, so colour is view-dependent as the scan was trained (sh_bands). It runs only on the frames whose view changed (WorkBuffer.mark_dirty() + WorkBuffer.before_frame()), so the decode and the modifier’s Jacobian are paid per view change, not per frame.

  • SplatNode (draw) — one instanced draw of INSTANCE_SIZE quads per instance (128, so a 6.8 M-splat scan is ~53 K instances), the vertex shader pulling each quad’s splat through the R32F order texture. The quad spans 2√2 σ (the kernel is the exact gaussian over it, pulled to zero at the edge), is shrunk to the radius where alpha·gaussian drops under 1/255, clamped at the viewport, culled below a pixel size and outside the frustum, and dropped only far beyond the clamp (a safety valve: thousands of screen-filling quads hang a GPU); the 0.3 px dilation’s extra opacity is compensated (antialias). Premultiplied over-blending, no depth, its own fixed bin (RUNTIME rule 18).

  • SplatSorter (sort) — the back-to-front order on a worker thread, from the work buffer’s depth plane: the pass also writes each splat’s view depth and a draw flag (in front, inside the frustum, visible) for the scene camera it is given (WorkBuffer.set_camera()), that one plane is copied to RAM after an update, and acron.gsplat.GSplatSorter reads it and counting-sorts natively with the GIL released (the engine always builds it; there is no Python fallback). So the modifier is GLSL-only: whatever it does to a splat, the order follows, with no CPU twin. The visible set alone is ordered and drawn: instance_count follows it. submit(fn) still takes a custom depth function for the cases that want one.

  • LinearSplatBuffer (linear) — the opt-in linear-light path: the splats blend in an RGBA16F buffer and a composite pass tonemaps and gamma-encodes the result into the window.

  • shaders holds the GLSL and builds the shaders; sh is the CPU mirror of the shader’s spherical-harmonic evaluation (native).

No numpy anywhere: the array work (reading, packing, the CPU decode, the SH evaluation, the sort) is C++ in acron.gsplat; Python holds Acron arrays, memoryviews and SplatArray s.

The render options switch live, on the same objects — no GPU object is recreated: WorkBuffer.set_sh_bands() and SplatNode.set_options() swap one shader each (the targets, geometry, order texture and uniform arrays stay), LinearSplatBuffer.attach() / detach() move a node between the direct and the linear path (a reparent), and LinearSplatBuffer.set_tonemap() is a uniform write.

This needs the GL 4.1 core profile (PRC): gl_InstanceID, texelFetch and float render targets, none of which the 2.1 compat context has (RUNTIME rule 17’s “no gl_VertexID at 120” is the same wall). Acron’s RGBA RAM images are BGRA (acron.gpu): the planes are swizzled on upload so .rgba in GLSL reads the SOG’s channels.