splatDraw.ts

src/render/splatDraw.ts · raw · all examples · site home

Exports: DrawVertexquadCornerquadIndicesDrawOptionsmakeDrawMaterialsetDrawNodesoutputIsLinear

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
// The splat draw: a quad per splat, 4 vertices indexed by 2 triangles, no
// vertex attributes, `quads` quads per instance (see quadIndices). The
// vertex shader takes the splat index of quad i from `order`, decodes the
// projected words of the eye being drawn and places a quad corner; the
// fragment shader evaluates the method's density and blends premultiplied
// color "over" the target.
import * as THREE from "three";
import { NodeMaterial, type WebGPURenderer } from "three/webgpu";
import type { GpuArrayReader } from "../gpu/GpuArrayReader";
import { hdrToneMappingActive } from "../gpu/hdr";
import { TSL, type TslNode } from "../gpu/tsl";
import { fragmentAlpha, type SplatVaryings } from "./projection.tsl";
import { SplatCamera } from "./splatCamera";
import { SplatParams } from "./splatParams";
import type { SplatProjection } from "./SplatProjection";
import { splatViewIndex } from "./viewIndex";

const { Discard, Fn, If, instanceIndex, log, max, min, select, sqrt, sRGBTransferEOTF, struct, uint, varyingProperty, vec2, vec3, vec4, vertexIndex } = TSL;

// What a method's vertex step fills, plus what the fragment needs
export const DrawVertex = struct({
  position: "vec4",
  uv: "vec2",
  localDir: "vec3",
  localCross: "vec3",
  c2: "float",
  rgb: "vec3",
  opacity: "float",
  cutoff: "float",
}, "SplatDrawVertex");

// Corner of quad vertex 0..3, x and y in {-1, 1}: bit 0 and bit 1 of v.
// An indexed draw passes the index value as vertexIndex, so the GPU's
// vertex cache shades the 2 shared corners once: 4 invocations, not 6.
export const quadCorner = (v: TslNode) => vec2(v.bitAnd(uint(1)), v.shiftRight(uint(1)).bitAnd(uint(1))).mul(2.0).sub(1.0);

// The index buffer of `quads` quads: 2 triangles over vertices 4q..4q+3.
// One instance draws them all, `quads` splats: GPUs run an instance's
// vertices together, so instances of 4 leave most of that work idle.
export function quadIndices(quads: number) {
  const indices = new Uint16Array(quads * 6);
  for (let q = 0; q < quads; q += 1) {
    indices.set([0, 1, 2, 2, 1, 3].map((corner) => q * 4 + corner), q * 6);
  }
  return indices;
}

export interface DrawOptions {
  projection: SplatProjection;
  eyes: number;
  // Reader of the projected bundle (its count bounds the indices drawn)
  projected: GpuArrayReader;
  // The splat index of the i-th splat drawn, and how many are drawn
  order: (i: TslNode) => TslNode;
  count: TslNode;
  // Quads per instance (quadIndices)
  quads: number;
  // 1 when the output stores linear color (see outputIsLinear)
  encodeLinear: TslNode;
}

// A splat material: premultiplied "over" blending, depth tested, not written
export function makeDrawMaterial(options: DrawOptions): NodeMaterial {
  const material = new NodeMaterial();
  material.name = "radix_splatDraw";
  material.transparent = true;
  material.blending = THREE.CustomBlending;
  material.blendSrc = THREE.OneFactor;
  material.blendDst = THREE.OneMinusSrcAlphaFactor;
  material.blendSrcAlpha = THREE.OneFactor;
  material.blendDstAlpha = THREE.OneMinusSrcAlphaFactor;
  material.depthTest = true;
  material.depthWrite = false;

  // Transparent DoubleSide is otherwise drawn as back then front faces,
  // which reorders the sorted splats
  material.side = THREE.DoubleSide;
  material.forceSinglePass = true;
  material.fog = false;

  setDrawNodes(material, options);
  return material;
}

// Build the material's shaders for `options`, in place: three rebuilds its
// pipeline on the next draw (the key tells the old and new shaders apart)
export function setDrawNodes(material: NodeMaterial, options: DrawOptions) {
  const { vertexNode, fragmentNode } = drawNodes(options);
  material.vertexNode = vertexNode;
  material.fragmentNode = fragmentNode;
  const key = `splatDraw:${nextDrawKey++}`;
  material.customProgramCacheKey = () => key;
  material.needsUpdate = true;
}

let nextDrawKey = 0;

function drawNodes({ projection, eyes, projected, order, count, quads, encodeLinear }: DrawOptions) {
  const params = SplatParams.nodes;
  const v = eyes === 1 ? uint(0) : splatViewIndex;

  // Written by the vertex stage, read by the fragment stage; only the
  // method's own varyings are declared
  const types = { uv: "vec2", localDir: "vec3", localCross: "vec3", c2: "float" } as const;
  const varyings = Object.fromEntries(projection.varyings.map((name) => [name, varyingProperty(types[name], `vSplat_${name}`)])) as Partial<SplatVaryings>;
  const rgb = varyingProperty("vec3", "vSplatRgb");
  const opacity = varyingProperty("float", "vSplatOpacity");
  const cutoff = varyingProperty("float", "vSplatCutoff");

  const vertex = Fn(() => {
    const out = DrawVertex().toVar();
    out.get("position").assign(vec4(0.0, 0.0, 2.0, 1.0));
    out.get("uv").assign(vec2(0.0));
    out.get("localDir").assign(vec3(0.0));
    out.get("localCross").assign(vec3(0.0));
    out.get("c2").assign(0.0);
    out.get("rgb").assign(vec3(0.0));
    out.get("opacity").assign(0.0);
    out.get("cutoff").assign(0.0);

    const i = instanceIndex.mul(uint(quads)).add(vertexIndex.shiftRight(uint(2))).toVar();
    const index = select(i.lessThan(count), order(i), projected.count).toVar();
    If(index.lessThan(projected.count), () => {
      // The head words, then the eye's (chosen by the view index in stereo)
      const words = projection.loadWords(projected, eyes, index);
      const head = words.slice(0, projection.headWords);
      const eyeWords = words.slice(head.length, head.length + projection.eyeWords).map((word, k) =>
        eyes === 1 ? word : select(v.equal(uint(0)), word, words[head.length + projection.eyeWords + k]).toVar());
      const look = projection.look(head, eyeWords);

      If(look.visible, () => {
        // The std devs the projection sized the bound with, and where to
        // cut. Opacities above 1 are LoD stretches the projection kept
        // (with lodInflate it grew the splat and stored 1 instead).
        const alpha = look.opacity.toVar();
        const bound = params.maxStdDev.toVar();
        const cut = params.maxStdDev.toVar();
        If(alpha.greaterThan(1.0), () => {
          alpha.assign(min(alpha.mul(4.0).sub(3.0), 5.0));
          bound.assign(params.maxStdDev.add(alpha.sub(1.0).mul(0.7)));
          cut.assign(bound);
        }).ElseIf(params.falloff.greaterThanEqual(1.0), () => {
          // Beyond this radius opacity * exp(-D/2) < minAlpha
          cut.assign(min(cut, sqrt(log(max(alpha, params.minAlpha).div(params.minAlpha)).mul(2.0))));
        });

        projection.vertex(head, eyeWords, SplatCamera.eye(v), quadCorner(vertexIndex), bound, cut, out);
        // Storage keeps signed colors (LoD merges, SH); a negative one would
        // give NaN in the sRGB decode and darken float targets
        out.get("rgb").assign(max(look.rgb, vec3(0.0)));
        out.get("opacity").assign(alpha);
        out.get("cutoff").assign(cut);
      });
    });

    for (const name of projection.varyings) {
      varyings[name]!.assign(out.get(name));
    }
    rgb.assign(out.get("rgb"));
    opacity.assign(out.get("opacity"));
    cutoff.assign(out.get("cutoff"));
    return out.get("position");
  });

  const fragment = Fn(() => {
    const z2 = projection.density(varyings as SplatVaryings).toVar();
    If(z2.greaterThan(cutoff.mul(cutoff)), () => {
      Discard();
    });

    const alpha = fragmentAlpha(z2, opacity, params.falloff).toVar();
    If(alpha.lessThan(params.minAlpha), () => {
      Discard();
    });

    const color = select(encodeLinear.greaterThan(0.5), sRGBTransferEOTF(rgb), rgb);
    return vec4(color.mul(alpha), alpha);
  });

  return { vertexNode: vertex(), fragmentNode: fragment() };
}

// Whether the output encodes linear color: splats are sRGB, drawn as they
// are where the output keeps them (the canvas without three's linear
// framebuffer target, that target under Hdr.useToneMapping, which decodes
// it, plain 8-bit targets)
export function outputIsLinear(renderer: WebGPURenderer) {
  const target = renderer.getRenderTarget();
  if (target === null) {
    if (renderer.xr.isPresenting && (renderer.backend as { isWebGPUBackend?: boolean }).isWebGPUBackend) {
      const binding = (renderer.xr as unknown as { getWebGPUBinding?: () => { getPreferredColorFormat(): string } | null }).getWebGPUBinding?.();
      return binding?.getPreferredColorFormat().endsWith("-srgb") === true;
    }
    return (renderer as unknown as { needsFrameBufferTarget?: boolean }).needsFrameBufferTarget === true && !hdrToneMappingActive(renderer);
  }
  // Inside a render, three's framebuffer target is the current one
  if ((target as { isPostProcessingRenderTarget?: boolean }).isPostProcessingRenderTarget && hdrToneMappingActive(renderer)) {
    return false;
  }
  const texture = target.texture;
  return texture.colorSpace === THREE.SRGBColorSpace || texture.type !== THREE.UnsignedByteType;
}