sdf.ts

examples/spark/js/sdf.ts · raw · open the example · all examples · site home

Exports: SdfBlendSdfBlendModeSdfSphereSdfEditsdfModifier

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
// Spark's SplatEdit / SplatEditSdf (sphere shapes) as a world-space TSL
// modifier (Radix has no SDF edits). Each SdfEdit is a layer with a blend
// mode and up to maxSdfs sphere slots (uniforms: moving, recoloring, showing
// or hiding a sphere compiles nothing); its spheres are its SdfSphere
// children, placed in the world. The field math follows Spark's
// evaluateSdfArray / modulateSdfArray / applyRgbaDisplaceEdit.
//
//   const edit = new SdfEdit({ rgbaBlendMode: "add_rgba", sdfSmooth: 0.1, softEdge: 0.8 });
//   edit.add(new SdfSphere({ radius: 1, color, opacity: 0 }));
//   scene.add(edit);
//   splatRenderer.modifier = sdfModifier([edit]);
//   splatRenderer.modifierSpace = "world";
//   // every frame: edit.update()
import * as THREE from "three";
import { TSL, type SplatModifier, type SplatNode, type TslNode } from "@sparkjsdev/radix";

const { clamp, exp, float, length, log, max, mix, select, uniform, vec3, vec4 } = TSL;

// Spark's SplatEditRgbaBlendMode; DARKEN is not one of Spark's modes (its
// examples' DARKEN falls back to the MULTIPLY default)
export const SdfBlend = { MULTIPLY: "multiply", SET_RGB: "set_rgb", ADD_RGBA: "add_rgba", DARKEN: "multiply" } as const;
export type SdfBlendMode = (typeof SdfBlend)[keyof typeof SdfBlend];

const FAR = 1e9;

export class SdfSphere extends THREE.Object3D {
  readonly isSdfSphere = true;
  radius: number;
  color: THREE.Color;
  opacity: number;
  displace: THREE.Vector3;
  invert: boolean;

  constructor({ radius = 1, color = new THREE.Color(1, 1, 1), opacity = 1, displace = new THREE.Vector3(), invert = false } = {}) {
    super();
    this.radius = radius;
    this.color = color.clone();
    this.opacity = opacity;
    this.displace = displace.clone();
    this.invert = invert;
  }
}

const position = new THREE.Vector3();
const scale = new THREE.Vector3();

// Whether an object and its ancestors are visible
function visible(object: THREE.Object3D) {
  for (let node: THREE.Object3D | null = object; node; node = node.parent) {
    if (!node.visible) {
      return false;
    }
  }
  return true;
}

export class SdfEdit extends THREE.Object3D {
  rgbaBlendMode: SdfBlendMode;
  smooth: boolean;
  uniforms: { sdfSmooth: TslNode; softEdge: TslNode; invert: TslNode };
  slots: { center: TslNode; radius: TslNode; rgba: TslNode; displace: TslNode; sign: TslNode; enabled: TslNode }[];

  constructor({ rgbaBlendMode = SdfBlend.MULTIPLY as SdfBlendMode, sdfSmooth = 0, softEdge = 0, invert = false, maxSdfs = 4 } = {}) {
    super();
    this.rgbaBlendMode = rgbaBlendMode;
    // Smooth union or plain minimum, chosen when the shader is built
    this.smooth = sdfSmooth > 0;
    this.uniforms = {
      sdfSmooth: uniform(Math.max(sdfSmooth, 1e-6)),
      softEdge: uniform(softEdge),
      invert: uniform(invert ? 1 : 0),
    };
    this.slots = Array.from({ length: maxSdfs }, () => ({
      center: uniform(new THREE.Vector3()),
      radius: uniform(0),
      rgba: uniform(new THREE.Vector4()),
      displace: uniform(new THREE.Vector3()),
      sign: uniform(1),
      enabled: uniform(0),
    }));
  }

  get sdfSmooth(): number {
    return this.uniforms.sdfSmooth.value;
  }

  set sdfSmooth(value: number) {
    this.uniforms.sdfSmooth.value = Math.max(value, 1e-6);
  }

  get softEdge(): number {
    return this.uniforms.softEdge.value;
  }

  set softEdge(value: number) {
    this.uniforms.softEdge.value = value;
  }

  // Copies the spheres' world positions, sizes and colors into the slots
  update() {
    this.updateMatrixWorld();
    const spheres = this.children.filter((child): child is SdfSphere => (child as SdfSphere).isSdfSphere && visible(child));
    this.slots.forEach((slot, i) => {
      const sphere = spheres[i];
      slot.enabled.value = sphere && visible(this) ? 1 : 0;
      if (!sphere) {
        return;
      }
      sphere.matrixWorld.decompose(position, new THREE.Quaternion(), scale);
      slot.center.value.copy(position);
      slot.radius.value = sphere.radius * scale.x;
      slot.rgba.value.set(sphere.color.r, sphere.color.g, sphere.color.b, sphere.opacity);
      slot.displace.value.copy(sphere.displace);
      slot.sign.value = sphere.invert ? -1 : 1;
    });
  }

  // Applies the edit to `out` (a splat var, world space) in place
  apply(out: SplatNode) {
    const pos = out.get("center").toVar();
    const rgba = vec4(out.get("rgb"), out.get("opacity")).toVar();
    const distances = this.slots.map((slot) => select(slot.enabled.greaterThan(0.5), length(pos.sub(slot.center)).sub(slot.radius).mul(slot.sign), float(FAR)));

    let distance: TslNode;
    let sdfRgba: TslNode;
    let sdfDisplace: TslNode;
    if (this.smooth) {
      // A weighted soft minimum, the weights relative to the largest one
      const k = this.uniforms.sdfSmooth;
      const scaled = distances.map((d: TslNode) => d.negate().div(k).toVar());
      const top = scaled.reduce((a: TslNode, b: TslNode) => max(a, b)).toVar();
      const weights = scaled.map((s: TslNode) => exp(s.sub(top)).toVar());
      const total = weights.reduce((a: TslNode, b: TslNode) => a.add(b)).toVar();
      sdfRgba = weights.reduce((sum: TslNode, w: TslNode, i: number) => sum.add(this.slots[i].rgba.mul(w)), vec4(0)).div(total);
      sdfDisplace = weights.reduce((sum: TslNode, w: TslNode, i: number) => sum.add(this.slots[i].displace.mul(w)), vec3(0)).div(total);
      distance = log(total).negate().sub(top).mul(k);
    } else {
      distance = distances[0].toVar();
      sdfRgba = vec4(this.slots[0].rgba).toVar();
      sdfDisplace = vec3(this.slots[0].displace).toVar();
      for (let i = 1; i < this.slots.length; i += 1) {
        const nearer = distances[i].lessThan(distance);
        sdfRgba.assign(select(nearer, this.slots[i].rgba, sdfRgba));
        sdfDisplace.assign(select(nearer, this.slots[i].displace, sdfDisplace));
        distance.assign(select(nearer, distances[i], distance));
      }
    }
    distance = select(this.uniforms.invert.greaterThan(0.5), distance.negate(), distance).toVar();

    const soft = this.uniforms.softEdge;
    const modulate = select(soft.equal(0), select(distance.lessThan(0), float(1), float(0)), clamp(distance.negate().div(soft).add(0.5), 0, 1)).toVar();

    const target = this.rgbaBlendMode === SdfBlend.ADD_RGBA
      ? rgba.add(sdfRgba)
      : this.rgbaBlendMode === SdfBlend.SET_RGB
        ? vec4(sdfRgba.xyz, rgba.w.mul(sdfRgba.w))
        : rgba.mul(sdfRgba);
    const blended = mix(rgba, target, modulate).toVar();
    out.get("rgb").assign(blended.xyz);
    // Above 1 would read as a merged LoD splat and grow it (lodInflate)
    out.get("opacity").assign(clamp(blended.w, 0, 1));
    out.get("center").assign(pos.add(sdfDisplace.mul(modulate)));
  }
}

// A world-space SplatModifier applying `edits` in order
export function sdfModifier(edits: SdfEdit[]): SplatModifier {
  return (splat) => {
    const out = splat.toVar();
    edits.forEach((edit) => edit.apply(out));
    return out;
  };
}