collisionOverlay.ts

examples/lcc/collisionOverlay.ts · raw · open the example · all examples · site home

Exports: OverlaySettingsCollisionOverlay

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
203
204
205
206
207
208
209
210
211
212
213
// The collision debug overlay: under the scene's `model` (so in the file's
// frame), a wireframe of every loaded collision chunk in a color of its own,
// each chunk's box (bright once loaded), optionally the BVH's boxes at one
// depth (leaves above it included) for the chunk nearest the camera, and the
// walker's capsule and last ground hit. Lines are drawn after the splats,
// without a depth test by default (an x-ray view).
import * as THREE from "three";
import { BVH_NODE_BYTES, isLeaf, rightChild, type CollisionChunk, type Vec3 } from "./collision.js";
import type { CollisionStream } from "./CollisionStream.js";

export interface OverlaySettings {
  mesh: boolean;
  boxes: boolean;
  leaves: boolean;
  // BVH depth of the boxes shown (0: the root)
  leafDepth: number;
  depthTest: boolean;
  opacity: number;
}

const PALETTE = ["#ff595e", "#ffca3a", "#8ac926", "#1982c4", "#6a4c93", "#ff924c", "#52a675", "#c77dff", "#4cc9f0", "#f15bb5"];

// The 12 edges of each box as line segment pairs
function boxLines(boxes: number[][]): Float32Array {
  const out = new Float32Array(boxes.length * 72);
  let at = 0;
  const corner = (b: number[], i: number) => [i & 1 ? b[3] : b[0], i & 2 ? b[4] : b[1], i & 4 ? b[5] : b[2]];
  const edges = [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]];
  for (const b of boxes) {
    for (const [i, j] of edges) {
      out.set(corner(b, i), at);
      out.set(corner(b, j), at + 3);
      at += 6;
    }
  }
  return out;
}

// The BVH's boxes at `depth`, and leaves above it
function bvhBoxes(chunk: CollisionChunk, depth: number): number[][] {
  const { bvh } = chunk;
  const out: number[][] = [];
  const stack: [number, number][] = [[0, 0]];
  while (stack.length > 0 && out.length < 100_000) {
    const [at, d] = stack.pop()!;
    if (at + BVH_NODE_BYTES > bvh.byteLength) {
      continue;
    }
    if (d === depth || isLeaf(bvh, at)) {
      out.push(Array.from({ length: 6 }, (_, k) => bvh.getFloat32(at + k * 4, true)));
      continue;
    }
    stack.push([rightChild(bvh, at), d + 1], [at + BVH_NODE_BYTES, d + 1]);
  }
  return out;
}

export class CollisionOverlay {
  readonly group = new THREE.Group();
  private readonly meshes = new Map<string, THREE.LineSegments>();
  private readonly boxes = new THREE.LineSegments(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: "#ffffff", transparent: true }));
  private readonly leaves = new THREE.LineSegments(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: "#00e5ff", transparent: true }));
  private readonly capsule = new THREE.LineSegments(new THREE.BufferGeometry(), new THREE.LineBasicMaterial({ color: "#7CFC00", transparent: true }));
  private stream?: CollisionStream;
  private boxesKey = "";
  private leavesKey = "";

  constructor() {
    this.group.name = "collision overlay";
    this.group.add(this.boxes, this.leaves, this.capsule);
    for (const lines of [this.boxes, this.leaves, this.capsule]) {
      lines.renderOrder = 10;
      lines.frustumCulled = false;
    }
  }

  attach(stream: CollisionStream | undefined) {
    this.clear();
    this.stream = stream;
  }

  private clear() {
    for (const lines of this.meshes.values()) {
      lines.geometry.dispose();
      (lines.material as THREE.Material).dispose();
      this.group.remove(lines);
    }
    this.meshes.clear();
    this.boxesKey = this.leavesKey = "";
    this.setLines(this.boxes, new Float32Array(0));
    this.setLines(this.leaves, new Float32Array(0));
    this.setLines(this.capsule, new Float32Array(0));
  }

  private setLines(lines: THREE.LineSegments, positions: Float32Array) {
    lines.geometry.dispose();
    lines.geometry = new THREE.BufferGeometry();
    lines.geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  }

  // Once per frame: the eye (file frame) picks the chunk whose BVH boxes show;
  // the walker's feet and capsule, when walking
  update(settings: OverlaySettings, eye: Vec3, walker?: { feet: Vec3; up: Vec3; radius: number; height: number; hit?: Vec3 }) {
    const stream = this.stream;
    this.group.visible = stream !== undefined && (settings.mesh || settings.boxes || settings.leaves || walker !== undefined);
    if (!stream || !this.group.visible) {
      return;
    }
    const chunks = stream.set.chunks;
    // Wireframes of the loaded chunks
    for (const [id, lines] of this.meshes) {
      if (!chunks.has(id)) {
        lines.geometry.dispose();
        (lines.material as THREE.Material).dispose();
        this.group.remove(lines);
        this.meshes.delete(id);
      }
    }
    if (settings.mesh) {
      stream.entries.forEach((entry, i) => {
        const chunk = chunks.get(entry.id);
        if (chunk && !this.meshes.has(entry.id)) {
          const geometry = new THREE.BufferGeometry();
          geometry.setAttribute("position", new THREE.BufferAttribute(chunk.vertices, 3));
          geometry.setIndex(new THREE.BufferAttribute(chunk.faces, 1));
          const lines = new THREE.LineSegments(new THREE.WireframeGeometry(geometry), new THREE.LineBasicMaterial({ color: PALETTE[i % PALETTE.length], transparent: true }));
          geometry.dispose();
          lines.renderOrder = 10;
          lines.frustumCulled = false;
          this.meshes.set(entry.id, lines);
          this.group.add(lines);
        }
      });
    }
    for (const lines of this.meshes.values()) {
      lines.visible = settings.mesh;
    }

    // Chunk boxes, rebuilt when what is loaded changes
    const key = settings.boxes ? stream.entries.map((entry) => entry.state).join() : "";
    if (key !== this.boxesKey) {
      this.boxesKey = key;
      this.setLines(this.boxes, settings.boxes ? boxLines(stream.entries.map((entry) => Array.from(entry.box))) : new Float32Array(0));
    }
    this.boxes.visible = settings.boxes;

    // BVH boxes of the nearest loaded chunk
    let nearest: CollisionChunk | undefined;
    let best = Infinity;
    if (settings.leaves) {
      for (const chunk of chunks.values()) {
        const b = chunk.box;
        const d = Math.hypot(Math.max(b[0] - eye.x, 0, eye.x - b[3]), Math.max(b[1] - eye.y, 0, eye.y - b[4]), Math.max(b[2] - eye.z, 0, eye.z - b[5]));
        if (d < best) {
          best = d;
          nearest = chunk;
        }
      }
    }
    const leavesKey = nearest ? `${nearest.id}|${settings.leafDepth}` : "";
    if (leavesKey !== this.leavesKey) {
      this.leavesKey = leavesKey;
      this.setLines(this.leaves, nearest ? boxLines(bvhBoxes(nearest, Math.max(0, Math.round(settings.leafDepth)))) : new Float32Array(0));
    }
    this.leaves.visible = settings.leaves;

    // The walker: two rings and four lines of its capsule, a cross at the hit
    if (walker) {
      this.setLines(this.capsule, capsuleLines(walker));
    }
    this.capsule.visible = walker !== undefined;

    for (const lines of [...this.meshes.values(), this.boxes, this.leaves, this.capsule]) {
      const material = lines.material as THREE.LineBasicMaterial;
      material.opacity = settings.opacity;
      material.depthTest = settings.depthTest;
      material.depthWrite = false;
    }
  }

  dispose() {
    this.clear();
  }
}

function capsuleLines({ feet, up, radius, height, hit }: { feet: Vec3; up: Vec3; radius: number; height: number; hit?: Vec3 }): Float32Array {
  const u = new THREE.Vector3(up.x, up.y, up.z).normalize();
  const a = new THREE.Vector3(1, 0, 0);
  if (Math.abs(a.dot(u)) > 0.9) {
    a.set(0, 1, 0);
  }
  const s = a.clone().cross(u).normalize();
  const t = u.clone().cross(s).normalize();
  const base = new THREE.Vector3(feet.x, feet.y, feet.z);
  const out: number[] = [];
  const point = (h: number, angle: number, r = radius) => base.clone().addScaledVector(u, h).addScaledVector(s, Math.cos(angle) * r).addScaledVector(t, Math.sin(angle) * r);
  const segments = 24;
  for (const h of [radius, height - radius]) {
    for (let i = 0; i < segments; i += 1) {
      out.push(...point(h, (i / segments) * Math.PI * 2).toArray(), ...point(h, ((i + 1) / segments) * Math.PI * 2).toArray());
    }
  }
  for (let i = 0; i < 4; i += 1) {
    out.push(...point(radius, (i / 4) * Math.PI * 2).toArray(), ...point(height - radius, (i / 4) * Math.PI * 2).toArray());
  }
  if (hit) {
    const c = new THREE.Vector3(hit.x, hit.y, hit.z);
    const r = radius * 0.6;
    out.push(...c.clone().addScaledVector(s, -r).toArray(), ...c.clone().addScaledVector(s, r).toArray());
    out.push(...c.clone().addScaledVector(t, -r).toArray(), ...c.clone().addScaledVector(t, r).toArray());
  }
  return new Float32Array(out);
}