wlyu-adobe commited on
Commit
d8d23f5
·
1 Parent(s): 46cadc4

Initial commit

Browse files
Files changed (5) hide show
  1. README.md +5 -1
  2. app.py +137 -3
  3. convert.py +85 -0
  4. main.js +1484 -0
  5. splat_viewer.html +277 -0
README.md CHANGED
@@ -20,6 +20,7 @@ Transform a single portrait image into a complete 3D head model using multi-view
20
  - **Automatic Face Detection**: Optional auto-cropping and alignment
21
  - **Multi-view Generation**: Creates 6 consistent views using diffusion models
22
  - **3D Reconstruction**: Generates high-quality 3D Gaussian splats
 
23
  - **Turntable Animation**: Exports rotating 360° video
24
  - **Downloadable Model**: Get the 3D model as a .ply file
25
 
@@ -32,7 +33,10 @@ Transform a single portrait image into a complete 3D head model using multi-view
32
  - Random Seed: For reproducible results
33
  - Generation Steps: Higher = better quality but slower
34
  3. Click Submit and wait for processing
35
- 4. Download the 3D model or turntable video
 
 
 
36
 
37
  ## Citation
38
 
 
20
  - **Automatic Face Detection**: Optional auto-cropping and alignment
21
  - **Multi-view Generation**: Creates 6 consistent views using diffusion models
22
  - **3D Reconstruction**: Generates high-quality 3D Gaussian splats
23
+ - **Interactive 3D Viewer**: WebGL-based viewer for exploring the model (based on [antimatter15/splat](https://github.com/antimatter15/splat))
24
  - **Turntable Animation**: Exports rotating 360° video
25
  - **Downloadable Model**: Get the 3D model as a .ply file
26
 
 
33
  - Random Seed: For reproducible results
34
  - Generation Steps: Higher = better quality but slower
35
  3. Click Submit and wait for processing
36
+ 4. View outputs:
37
+ - Download the Interactive 3D Viewer HTML file and open it in your browser for full-screen exploration
38
+ - Watch the turntable animation
39
+ - Download the 3D model (.ply file) for use in other software
40
 
41
  ## Citation
42
 
app.py CHANGED
@@ -27,20 +27,28 @@ from huggingface_hub import snapshot_download
27
  # Install diff-gaussian-rasterization at runtime (requires GPU)
28
  import subprocess
29
  import sys
 
30
  try:
31
  import diff_gaussian_rasterization
32
  except ImportError:
33
  print("Installing diff-gaussian-rasterization...")
 
 
 
 
34
  subprocess.check_call([
35
  sys.executable, "-m", "pip", "install",
36
  "git+https://github.com/graphdeco-inria/diff-gaussian-rasterization"
37
- ])
38
  import diff_gaussian_rasterization
39
 
40
  from gslrm.model.gaussians_renderer import render_turntable, imageseq2video
41
  from mvdiffusion.pipelines.pipeline_mvdiffusion_unclip import StableUnCLIPImg2ImgPipeline
42
  from utils_folder.face_utils import preprocess_image, preprocess_image_without_cropping
43
 
 
 
 
44
  # HuggingFace repository configuration
45
  HF_REPO_ID = "wlyu/OpenFaceLift"
46
 
@@ -124,6 +132,120 @@ class FaceLiftPipeline:
124
 
125
  print("Models loaded successfully!")
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  def generate_3d_head(self, image_path, auto_crop=True, guidance_scale=3.0,
128
  random_seed=4, num_steps=50):
129
  """Generate 3D head from single image."""
@@ -216,6 +338,16 @@ class FaceLiftPipeline:
216
  ply_path = output_dir / "gaussians.ply"
217
  filtered_gaussians.save_ply(str(ply_path))
218
 
 
 
 
 
 
 
 
 
 
 
219
  # Save output image
220
  comp_image = rearrange(comp_image, "x v c h w -> (x h) (v w) c")
221
  comp_image = (comp_image.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
@@ -232,7 +364,7 @@ class FaceLiftPipeline:
232
  imageseq2video(turntable_frames, str(turntable_path), fps=30)
233
 
234
  return str(input_path), str(multiview_path), str(output_path), \
235
- str(turntable_path), str(ply_path)
236
 
237
  except Exception as e:
238
  raise gr.Error(f"Generation failed: {str(e)}")
@@ -253,11 +385,12 @@ def main():
253
  fn=pipeline.generate_3d_head,
254
  title="FaceLift: Single Image 3D Face Reconstruction",
255
  description="""
256
- Transform a single portrait image into a complete 3D head model.
257
 
258
  **Tips:**
259
  - Use high-quality portrait images with clear facial features
260
  - If face detection fails, try disabling auto-cropping and manually crop to square
 
261
  """,
262
  inputs=[
263
  gr.Image(type="filepath", label="Input Portrait Image"),
@@ -271,6 +404,7 @@ def main():
271
  gr.Image(label="Multi-view Generation"),
272
  gr.Image(label="3D Reconstruction"),
273
  gr.PlayableVideo(label="Turntable Animation"),
 
274
  gr.File(label="3D Model (.ply)"),
275
  ],
276
  examples=examples,
 
27
  # Install diff-gaussian-rasterization at runtime (requires GPU)
28
  import subprocess
29
  import sys
30
+ import os
31
  try:
32
  import diff_gaussian_rasterization
33
  except ImportError:
34
  print("Installing diff-gaussian-rasterization...")
35
+ # Set CUDA architecture for A100 (compute capability 8.0)
36
+ # This prevents the IndexError in _get_cuda_arch_flags
37
+ env = os.environ.copy()
38
+ env["TORCH_CUDA_ARCH_LIST"] = "8.0"
39
  subprocess.check_call([
40
  sys.executable, "-m", "pip", "install",
41
  "git+https://github.com/graphdeco-inria/diff-gaussian-rasterization"
42
+ ], env=env)
43
  import diff_gaussian_rasterization
44
 
45
  from gslrm.model.gaussians_renderer import render_turntable, imageseq2video
46
  from mvdiffusion.pipelines.pipeline_mvdiffusion_unclip import StableUnCLIPImg2ImgPipeline
47
  from utils_folder.face_utils import preprocess_image, preprocess_image_without_cropping
48
 
49
+ # Import convert function for PLY to SPLAT conversion
50
+ import convert as ply_to_splat
51
+
52
  # HuggingFace repository configuration
53
  HF_REPO_ID = "wlyu/OpenFaceLift"
54
 
 
132
 
133
  print("Models loaded successfully!")
134
 
135
+ def _create_viewer_html(self, splat_path):
136
+ """Create standalone HTML viewer for the gaussian splat."""
137
+ import base64
138
+
139
+ # Read the splat file and encode as base64
140
+ with open(splat_path, 'rb') as f:
141
+ splat_data = f.read()
142
+ splat_b64 = base64.b64encode(splat_data).decode('utf-8')
143
+
144
+ # Read the main.js content and modify it to use embedded data
145
+ with open(Path(__file__).parent / "main.js", 'r') as f:
146
+ js_content = f.read()
147
+
148
+ # Replace the URL fetching part with blob URL
149
+ js_content = js_content.replace(
150
+ 'params.get("url") || "train.splat"',
151
+ 'window.EMBEDDED_SPLAT_URL || "train.splat"'
152
+ )
153
+
154
+ html = f"""<!DOCTYPE html>
155
+ <html lang="en">
156
+ <head>
157
+ <meta charset="UTF-8">
158
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
159
+ <title>3D Gaussian Splat Viewer - FaceLift</title>
160
+ <style>
161
+ body {{
162
+ margin: 0;
163
+ padding: 0;
164
+ overflow: hidden;
165
+ font-family: Arial, sans-serif;
166
+ background: #000;
167
+ }}
168
+ #canvas {{
169
+ width: 100vw;
170
+ height: 100vh;
171
+ display: block;
172
+ }}
173
+ #info {{
174
+ position: absolute;
175
+ top: 10px;
176
+ left: 10px;
177
+ background: rgba(0, 0, 0, 0.7);
178
+ color: white;
179
+ padding: 10px;
180
+ border-radius: 5px;
181
+ font-size: 12px;
182
+ max-width: 300px;
183
+ z-index: 1000;
184
+ }}
185
+ #info h3 {{
186
+ margin: 0 0 10px 0;
187
+ font-size: 14px;
188
+ }}
189
+ #loading {{
190
+ position: absolute;
191
+ top: 50%;
192
+ left: 50%;
193
+ transform: translate(-50%, -50%);
194
+ color: white;
195
+ font-size: 18px;
196
+ text-align: center;
197
+ z-index: 999;
198
+ }}
199
+ </style>
200
+ </head>
201
+ <body>
202
+ <canvas id="canvas"></canvas>
203
+ <div id="loading">Loading 3D model...</div>
204
+ <div id="info">
205
+ <h3>Controls</h3>
206
+ <p><b>Mouse:</b> Click and drag to orbit</p>
207
+ <p><b>Right click/Ctrl+drag:</b> Move forward/back (up/down), strafe (left/right)</p>
208
+ <p><b>Arrow keys:</b> Move forward/back, strafe left/right</p>
209
+ <p><b>WASD:</b> Rotate camera</p>
210
+ <p><b>Space:</b> Jump</p>
211
+ <p><b>Q/E:</b> Roll camera</p>
212
+ </div>
213
+ <script>
214
+ // Convert base64 to blob and create URL
215
+ const SPLAT_DATA_B64 = '{splat_b64}';
216
+
217
+ function b64toBlob(b64Data, contentType='application/octet-stream', sliceSize=512) {{
218
+ const byteCharacters = atob(b64Data);
219
+ const byteArrays = [];
220
+ for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {{
221
+ const slice = byteCharacters.slice(offset, offset + sliceSize);
222
+ const byteNumbers = new Array(slice.length);
223
+ for (let i = 0; i < slice.length; i++) {{
224
+ byteNumbers[i] = slice.charCodeAt(i);
225
+ }}
226
+ const byteArray = new Uint8Array(byteNumbers);
227
+ byteArrays.push(byteArray);
228
+ }}
229
+ return new Blob(byteArrays, {{type: contentType}});
230
+ }}
231
+
232
+ // Create blob URL for embedded splat data
233
+ const blob = b64toBlob(SPLAT_DATA_B64);
234
+ window.EMBEDDED_SPLAT_URL = URL.createObjectURL(blob);
235
+
236
+ // Remove loading message once render starts
237
+ setTimeout(() => {{
238
+ const loading = document.getElementById('loading');
239
+ if (loading) loading.style.display = 'none';
240
+ }}, 2000);
241
+ </script>
242
+ <script>
243
+ {js_content}
244
+ </script>
245
+ </body>
246
+ </html>"""
247
+ return html
248
+
249
  def generate_3d_head(self, image_path, auto_crop=True, guidance_scale=3.0,
250
  random_seed=4, num_steps=50):
251
  """Generate 3D head from single image."""
 
338
  ply_path = output_dir / "gaussians.ply"
339
  filtered_gaussians.save_ply(str(ply_path))
340
 
341
+ # Convert PLY to SPLAT format for web viewer
342
+ splat_path = output_dir / "gaussians.splat"
343
+ ply_to_splat.convert(str(ply_path), str(splat_path))
344
+
345
+ # Create HTML viewer
346
+ viewer_html = self._create_viewer_html(str(splat_path))
347
+ viewer_path = output_dir / "viewer.html"
348
+ with open(viewer_path, 'w') as f:
349
+ f.write(viewer_html)
350
+
351
  # Save output image
352
  comp_image = rearrange(comp_image, "x v c h w -> (x h) (v w) c")
353
  comp_image = (comp_image.cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
 
364
  imageseq2video(turntable_frames, str(turntable_path), fps=30)
365
 
366
  return str(input_path), str(multiview_path), str(output_path), \
367
+ str(turntable_path), str(viewer_path), str(ply_path)
368
 
369
  except Exception as e:
370
  raise gr.Error(f"Generation failed: {str(e)}")
 
385
  fn=pipeline.generate_3d_head,
386
  title="FaceLift: Single Image 3D Face Reconstruction",
387
  description="""
388
+ Transform a single portrait image into a complete 3D head model with an interactive WebGL viewer.
389
 
390
  **Tips:**
391
  - Use high-quality portrait images with clear facial features
392
  - If face detection fails, try disabling auto-cropping and manually crop to square
393
+ - Download the Interactive 3D Viewer HTML file to explore your model in full screen
394
  """,
395
  inputs=[
396
  gr.Image(type="filepath", label="Input Portrait Image"),
 
404
  gr.Image(label="Multi-view Generation"),
405
  gr.Image(label="3D Reconstruction"),
406
  gr.PlayableVideo(label="Turntable Animation"),
407
+ gr.File(label="Interactive 3D Viewer (.html) - Download & Open"),
408
  gr.File(label="3D Model (.ply)"),
409
  ],
410
  examples=examples,
convert.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # You can use this to convert a .ply file to a .splat file programmatically in python
2
+ # Alternatively you can drag and drop a .ply file into the viewer at https://antimatter15.com/splat
3
+
4
+ from plyfile import PlyData
5
+ import numpy as np
6
+ import argparse
7
+ from io import BytesIO
8
+
9
+
10
+ def process_ply_to_splat(ply_file_path):
11
+ plydata = PlyData.read(ply_file_path)
12
+ vert = plydata["vertex"]
13
+ sorted_indices = np.argsort(
14
+ -np.exp(vert["scale_0"] + vert["scale_1"] + vert["scale_2"])
15
+ / (1 + np.exp(-vert["opacity"]))
16
+ )
17
+ buffer = BytesIO()
18
+ for idx in sorted_indices:
19
+ v = plydata["vertex"][idx]
20
+ position = np.array([v["x"], v["y"], v["z"]], dtype=np.float32)
21
+ scales = np.exp(
22
+ np.array(
23
+ [v["scale_0"], v["scale_1"], v["scale_2"]],
24
+ dtype=np.float32,
25
+ )
26
+ )
27
+ rot = np.array(
28
+ [v["rot_0"], v["rot_1"], v["rot_2"], v["rot_3"]],
29
+ dtype=np.float32,
30
+ )
31
+ SH_C0 = 0.28209479177387814
32
+ color = np.array(
33
+ [
34
+ 0.5 + SH_C0 * v["f_dc_0"],
35
+ 0.5 + SH_C0 * v["f_dc_1"],
36
+ 0.5 + SH_C0 * v["f_dc_2"],
37
+ 1 / (1 + np.exp(-v["opacity"])),
38
+ ]
39
+ )
40
+ buffer.write(position.tobytes())
41
+ buffer.write(scales.tobytes())
42
+ buffer.write((color * 255).clip(0, 255).astype(np.uint8).tobytes())
43
+ buffer.write(
44
+ ((rot / np.linalg.norm(rot)) * 128 + 128)
45
+ .clip(0, 255)
46
+ .astype(np.uint8)
47
+ .tobytes()
48
+ )
49
+
50
+ return buffer.getvalue()
51
+
52
+
53
+ def save_splat_file(splat_data, output_path):
54
+ with open(output_path, "wb") as f:
55
+ f.write(splat_data)
56
+
57
+
58
+ def convert(input_path, output_path):
59
+ """Convert a PLY file to SPLAT format."""
60
+ splat_data = process_ply_to_splat(input_path)
61
+ save_splat_file(splat_data, output_path)
62
+ return output_path
63
+
64
+
65
+ def main():
66
+ parser = argparse.ArgumentParser(description="Convert PLY files to SPLAT format.")
67
+ parser.add_argument(
68
+ "input_files", nargs="+", help="The input PLY files to process."
69
+ )
70
+ parser.add_argument(
71
+ "--output", "-o", default="output.splat", help="The output SPLAT file."
72
+ )
73
+ args = parser.parse_args()
74
+ for input_file in args.input_files:
75
+ print(f"Processing {input_file}...")
76
+ splat_data = process_ply_to_splat(input_file)
77
+ output_file = (
78
+ args.output if len(args.input_files) == 1 else input_file + ".splat"
79
+ )
80
+ save_splat_file(splat_data, output_file)
81
+ print(f"Saved {output_file}")
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
main.js ADDED
@@ -0,0 +1,1484 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let cameras = [
2
+ {
3
+ id: 0,
4
+ img_name: "00001",
5
+ width: 1959,
6
+ height: 1090,
7
+ position: [
8
+ -3.0089893469241797, -0.11086489695181866, -3.7527640949141428,
9
+ ],
10
+ rotation: [
11
+ [0.876134201218856, 0.06925962026449776, 0.47706599800804744],
12
+ [-0.04747421839895102, 0.9972110940209488, -0.057586739349882114],
13
+ [-0.4797239414934443, 0.027805376500959853, 0.8769787916452908],
14
+ ],
15
+ fy: 1164.6601287484507,
16
+ fx: 1159.5880733038064,
17
+ },
18
+ {
19
+ id: 1,
20
+ img_name: "00009",
21
+ width: 1959,
22
+ height: 1090,
23
+ position: [
24
+ -2.5199776022057296, -0.09704735754873686, -3.6247725540304545,
25
+ ],
26
+ rotation: [
27
+ [0.9982731285632193, -0.011928707708098955, -0.05751927260507243],
28
+ [0.0065061360949636325, 0.9955928229282383, -0.09355533724430458],
29
+ [0.058381769258182864, 0.09301955098900708, 0.9939511719154457],
30
+ ],
31
+ fy: 1164.6601287484507,
32
+ fx: 1159.5880733038064,
33
+ },
34
+ {
35
+ id: 2,
36
+ img_name: "00017",
37
+ width: 1959,
38
+ height: 1090,
39
+ position: [
40
+ -0.7737533667465242, -0.3364271945329695, -2.9358969417573753,
41
+ ],
42
+ rotation: [
43
+ [0.9998813418672372, 0.013742375651625236, -0.0069605529394208224],
44
+ [-0.014268370388586709, 0.996512943252834, -0.08220929105659476],
45
+ [0.00580653013657589, 0.08229885200307129, 0.9965907801935302],
46
+ ],
47
+ fy: 1164.6601287484507,
48
+ fx: 1159.5880733038064,
49
+ },
50
+ {
51
+ id: 3,
52
+ img_name: "00025",
53
+ width: 1959,
54
+ height: 1090,
55
+ position: [
56
+ 1.2198221749590001, -0.2196687861401182, -2.3183162007028453,
57
+ ],
58
+ rotation: [
59
+ [0.9208648867765482, 0.0012010625395201253, 0.389880004297208],
60
+ [-0.06298204172269357, 0.987319521752825, 0.14571693239364383],
61
+ [-0.3847611242348369, -0.1587410451475895, 0.9092635249821667],
62
+ ],
63
+ fy: 1164.6601287484507,
64
+ fx: 1159.5880733038064,
65
+ },
66
+ {
67
+ id: 4,
68
+ img_name: "00033",
69
+ width: 1959,
70
+ height: 1090,
71
+ position: [
72
+ 1.742387858893817, -0.13848225198886954, -2.0566370113193146,
73
+ ],
74
+ rotation: [
75
+ [0.24669889292141334, -0.08370189346592856, -0.9654706879349405],
76
+ [0.11343747891376445, 0.9919082664242816, -0.05700815184573074],
77
+ [0.9624300466054861, -0.09545671285663988, 0.2541976029815521],
78
+ ],
79
+ fy: 1164.6601287484507,
80
+ fx: 1159.5880733038064,
81
+ },
82
+ {
83
+ id: 5,
84
+ img_name: "00041",
85
+ width: 1959,
86
+ height: 1090,
87
+ position: [
88
+ 3.6567309419223935, -0.16470990600750707, -1.3458085590422042,
89
+ ],
90
+ rotation: [
91
+ [0.2341293058324528, -0.02968330457755884, -0.9717522161434825],
92
+ [0.10270823606832301, 0.99469554638321, -0.005638106875665722],
93
+ [0.9667649592295676, -0.09848690996657204, 0.2359360976431732],
94
+ ],
95
+ fy: 1164.6601287484507,
96
+ fx: 1159.5880733038064,
97
+ },
98
+ {
99
+ id: 6,
100
+ img_name: "00049",
101
+ width: 1959,
102
+ height: 1090,
103
+ position: [
104
+ 3.9013554243203497, -0.2597500978038105, -0.8106154188297828,
105
+ ],
106
+ rotation: [
107
+ [0.6717235545638952, -0.015718162115524837, -0.7406351366386528],
108
+ [0.055627354673906296, 0.9980224478387622, 0.029270992841185218],
109
+ [0.7387104058127439, -0.060861588786650656, 0.6712695459756353],
110
+ ],
111
+ fy: 1164.6601287484507,
112
+ fx: 1159.5880733038064,
113
+ },
114
+ {
115
+ id: 7,
116
+ img_name: "00057",
117
+ width: 1959,
118
+ height: 1090,
119
+ position: [4.742994605467533, -0.05591660945412069, 0.9500365976084458],
120
+ rotation: [
121
+ [-0.17042655709210375, 0.01207080756938, -0.9852964448542146],
122
+ [0.1165090336695526, 0.9931575292530063, -0.00798543433078162],
123
+ [0.9784581921120181, -0.1161568667478904, -0.1706667764862097],
124
+ ],
125
+ fy: 1164.6601287484507,
126
+ fx: 1159.5880733038064,
127
+ },
128
+ {
129
+ id: 8,
130
+ img_name: "00065",
131
+ width: 1959,
132
+ height: 1090,
133
+ position: [4.34676307626522, 0.08168160516967145, 1.0876221470355405],
134
+ rotation: [
135
+ [-0.003575447631888379, -0.044792503246552894, -0.9989899137764799],
136
+ [0.10770152645126597, 0.9931680875192705, -0.04491693593046672],
137
+ [0.9941768441149182, -0.10775333677534978, 0.0012732004866391048],
138
+ ],
139
+ fy: 1164.6601287484507,
140
+ fx: 1159.5880733038064,
141
+ },
142
+ {
143
+ id: 9,
144
+ img_name: "00073",
145
+ width: 1959,
146
+ height: 1090,
147
+ position: [3.264984351114202, 0.078974937336732, 1.0117200284114904],
148
+ rotation: [
149
+ [-0.026919994628162257, -0.1565891128261527, -0.9872968974090509],
150
+ [0.08444552208239385, 0.983768234577625, -0.1583319754069128],
151
+ [0.9960643893290491, -0.0876350978794554, -0.013259786205163005],
152
+ ],
153
+ fy: 1164.6601287484507,
154
+ fx: 1159.5880733038064,
155
+ },
156
+ ];
157
+
158
+ let camera = cameras[0];
159
+
160
+ function getProjectionMatrix(fx, fy, width, height) {
161
+ const znear = 0.2;
162
+ const zfar = 200;
163
+ return [
164
+ [(2 * fx) / width, 0, 0, 0],
165
+ [0, -(2 * fy) / height, 0, 0],
166
+ [0, 0, zfar / (zfar - znear), 1],
167
+ [0, 0, -(zfar * znear) / (zfar - znear), 0],
168
+ ].flat();
169
+ }
170
+
171
+ function getViewMatrix(camera) {
172
+ const R = camera.rotation.flat();
173
+ const t = camera.position;
174
+ const camToWorld = [
175
+ [R[0], R[1], R[2], 0],
176
+ [R[3], R[4], R[5], 0],
177
+ [R[6], R[7], R[8], 0],
178
+ [
179
+ -t[0] * R[0] - t[1] * R[3] - t[2] * R[6],
180
+ -t[0] * R[1] - t[1] * R[4] - t[2] * R[7],
181
+ -t[0] * R[2] - t[1] * R[5] - t[2] * R[8],
182
+ 1,
183
+ ],
184
+ ].flat();
185
+ return camToWorld;
186
+ }
187
+ // function translate4(a, x, y, z) {
188
+ // return [
189
+ // ...a.slice(0, 12),
190
+ // a[0] * x + a[4] * y + a[8] * z + a[12],
191
+ // a[1] * x + a[5] * y + a[9] * z + a[13],
192
+ // a[2] * x + a[6] * y + a[10] * z + a[14],
193
+ // a[3] * x + a[7] * y + a[11] * z + a[15],
194
+ // ];
195
+ // }
196
+
197
+ function multiply4(a, b) {
198
+ return [
199
+ b[0] * a[0] + b[1] * a[4] + b[2] * a[8] + b[3] * a[12],
200
+ b[0] * a[1] + b[1] * a[5] + b[2] * a[9] + b[3] * a[13],
201
+ b[0] * a[2] + b[1] * a[6] + b[2] * a[10] + b[3] * a[14],
202
+ b[0] * a[3] + b[1] * a[7] + b[2] * a[11] + b[3] * a[15],
203
+ b[4] * a[0] + b[5] * a[4] + b[6] * a[8] + b[7] * a[12],
204
+ b[4] * a[1] + b[5] * a[5] + b[6] * a[9] + b[7] * a[13],
205
+ b[4] * a[2] + b[5] * a[6] + b[6] * a[10] + b[7] * a[14],
206
+ b[4] * a[3] + b[5] * a[7] + b[6] * a[11] + b[7] * a[15],
207
+ b[8] * a[0] + b[9] * a[4] + b[10] * a[8] + b[11] * a[12],
208
+ b[8] * a[1] + b[9] * a[5] + b[10] * a[9] + b[11] * a[13],
209
+ b[8] * a[2] + b[9] * a[6] + b[10] * a[10] + b[11] * a[14],
210
+ b[8] * a[3] + b[9] * a[7] + b[10] * a[11] + b[11] * a[15],
211
+ b[12] * a[0] + b[13] * a[4] + b[14] * a[8] + b[15] * a[12],
212
+ b[12] * a[1] + b[13] * a[5] + b[14] * a[9] + b[15] * a[13],
213
+ b[12] * a[2] + b[13] * a[6] + b[14] * a[10] + b[15] * a[14],
214
+ b[12] * a[3] + b[13] * a[7] + b[14] * a[11] + b[15] * a[15],
215
+ ];
216
+ }
217
+
218
+ function invert4(a) {
219
+ let b00 = a[0] * a[5] - a[1] * a[4];
220
+ let b01 = a[0] * a[6] - a[2] * a[4];
221
+ let b02 = a[0] * a[7] - a[3] * a[4];
222
+ let b03 = a[1] * a[6] - a[2] * a[5];
223
+ let b04 = a[1] * a[7] - a[3] * a[5];
224
+ let b05 = a[2] * a[7] - a[3] * a[6];
225
+ let b06 = a[8] * a[13] - a[9] * a[12];
226
+ let b07 = a[8] * a[14] - a[10] * a[12];
227
+ let b08 = a[8] * a[15] - a[11] * a[12];
228
+ let b09 = a[9] * a[14] - a[10] * a[13];
229
+ let b10 = a[9] * a[15] - a[11] * a[13];
230
+ let b11 = a[10] * a[15] - a[11] * a[14];
231
+ let det =
232
+ b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
233
+ if (!det) return null;
234
+ return [
235
+ (a[5] * b11 - a[6] * b10 + a[7] * b09) / det,
236
+ (a[2] * b10 - a[1] * b11 - a[3] * b09) / det,
237
+ (a[13] * b05 - a[14] * b04 + a[15] * b03) / det,
238
+ (a[10] * b04 - a[9] * b05 - a[11] * b03) / det,
239
+ (a[6] * b08 - a[4] * b11 - a[7] * b07) / det,
240
+ (a[0] * b11 - a[2] * b08 + a[3] * b07) / det,
241
+ (a[14] * b02 - a[12] * b05 - a[15] * b01) / det,
242
+ (a[8] * b05 - a[10] * b02 + a[11] * b01) / det,
243
+ (a[4] * b10 - a[5] * b08 + a[7] * b06) / det,
244
+ (a[1] * b08 - a[0] * b10 - a[3] * b06) / det,
245
+ (a[12] * b04 - a[13] * b02 + a[15] * b00) / det,
246
+ (a[9] * b02 - a[8] * b04 - a[11] * b00) / det,
247
+ (a[5] * b07 - a[4] * b09 - a[6] * b06) / det,
248
+ (a[0] * b09 - a[1] * b07 + a[2] * b06) / det,
249
+ (a[13] * b01 - a[12] * b03 - a[14] * b00) / det,
250
+ (a[8] * b03 - a[9] * b01 + a[10] * b00) / det,
251
+ ];
252
+ }
253
+
254
+ function rotate4(a, rad, x, y, z) {
255
+ let len = Math.hypot(x, y, z);
256
+ x /= len;
257
+ y /= len;
258
+ z /= len;
259
+ let s = Math.sin(rad);
260
+ let c = Math.cos(rad);
261
+ let t = 1 - c;
262
+ let b00 = x * x * t + c;
263
+ let b01 = y * x * t + z * s;
264
+ let b02 = z * x * t - y * s;
265
+ let b10 = x * y * t - z * s;
266
+ let b11 = y * y * t + c;
267
+ let b12 = z * y * t + x * s;
268
+ let b20 = x * z * t + y * s;
269
+ let b21 = y * z * t - x * s;
270
+ let b22 = z * z * t + c;
271
+ return [
272
+ a[0] * b00 + a[4] * b01 + a[8] * b02,
273
+ a[1] * b00 + a[5] * b01 + a[9] * b02,
274
+ a[2] * b00 + a[6] * b01 + a[10] * b02,
275
+ a[3] * b00 + a[7] * b01 + a[11] * b02,
276
+ a[0] * b10 + a[4] * b11 + a[8] * b12,
277
+ a[1] * b10 + a[5] * b11 + a[9] * b12,
278
+ a[2] * b10 + a[6] * b11 + a[10] * b12,
279
+ a[3] * b10 + a[7] * b11 + a[11] * b12,
280
+ a[0] * b20 + a[4] * b21 + a[8] * b22,
281
+ a[1] * b20 + a[5] * b21 + a[9] * b22,
282
+ a[2] * b20 + a[6] * b21 + a[10] * b22,
283
+ a[3] * b20 + a[7] * b21 + a[11] * b22,
284
+ ...a.slice(12, 16),
285
+ ];
286
+ }
287
+
288
+ function translate4(a, x, y, z) {
289
+ return [
290
+ ...a.slice(0, 12),
291
+ a[0] * x + a[4] * y + a[8] * z + a[12],
292
+ a[1] * x + a[5] * y + a[9] * z + a[13],
293
+ a[2] * x + a[6] * y + a[10] * z + a[14],
294
+ a[3] * x + a[7] * y + a[11] * z + a[15],
295
+ ];
296
+ }
297
+
298
+ function createWorker(self) {
299
+ let buffer;
300
+ let vertexCount = 0;
301
+ let viewProj;
302
+ // 6*4 + 4 + 4 = 8*4
303
+ // XYZ - Position (Float32)
304
+ // XYZ - Scale (Float32)
305
+ // RGBA - colors (uint8)
306
+ // IJKL - quaternion/rot (uint8)
307
+ const rowLength = 3 * 4 + 3 * 4 + 4 + 4;
308
+ let lastProj = [];
309
+ let depthIndex = new Uint32Array();
310
+ let lastVertexCount = 0;
311
+
312
+ var _floatView = new Float32Array(1);
313
+ var _int32View = new Int32Array(_floatView.buffer);
314
+
315
+ function floatToHalf(float) {
316
+ _floatView[0] = float;
317
+ var f = _int32View[0];
318
+
319
+ var sign = (f >> 31) & 0x0001;
320
+ var exp = (f >> 23) & 0x00ff;
321
+ var frac = f & 0x007fffff;
322
+
323
+ var newExp;
324
+ if (exp == 0) {
325
+ newExp = 0;
326
+ } else if (exp < 113) {
327
+ newExp = 0;
328
+ frac |= 0x00800000;
329
+ frac = frac >> (113 - exp);
330
+ if (frac & 0x01000000) {
331
+ newExp = 1;
332
+ frac = 0;
333
+ }
334
+ } else if (exp < 142) {
335
+ newExp = exp - 112;
336
+ } else {
337
+ newExp = 31;
338
+ frac = 0;
339
+ }
340
+
341
+ return (sign << 15) | (newExp << 10) | (frac >> 13);
342
+ }
343
+
344
+ function packHalf2x16(x, y) {
345
+ return (floatToHalf(x) | (floatToHalf(y) << 16)) >>> 0;
346
+ }
347
+
348
+ function generateTexture() {
349
+ if (!buffer) return;
350
+ const f_buffer = new Float32Array(buffer);
351
+ const u_buffer = new Uint8Array(buffer);
352
+
353
+ var texwidth = 1024 * 2; // Set to your desired width
354
+ var texheight = Math.ceil((2 * vertexCount) / texwidth); // Set to your desired height
355
+ var texdata = new Uint32Array(texwidth * texheight * 4); // 4 components per pixel (RGBA)
356
+ var texdata_c = new Uint8Array(texdata.buffer);
357
+ var texdata_f = new Float32Array(texdata.buffer);
358
+
359
+ // Here we convert from a .splat file buffer into a texture
360
+ // With a little bit more foresight perhaps this texture file
361
+ // should have been the native format as it'd be very easy to
362
+ // load it into webgl.
363
+ for (let i = 0; i < vertexCount; i++) {
364
+ // x, y, z
365
+ texdata_f[8 * i + 0] = f_buffer[8 * i + 0];
366
+ texdata_f[8 * i + 1] = f_buffer[8 * i + 1];
367
+ texdata_f[8 * i + 2] = f_buffer[8 * i + 2];
368
+
369
+ // r, g, b, a
370
+ texdata_c[4 * (8 * i + 7) + 0] = u_buffer[32 * i + 24 + 0];
371
+ texdata_c[4 * (8 * i + 7) + 1] = u_buffer[32 * i + 24 + 1];
372
+ texdata_c[4 * (8 * i + 7) + 2] = u_buffer[32 * i + 24 + 2];
373
+ texdata_c[4 * (8 * i + 7) + 3] = u_buffer[32 * i + 24 + 3];
374
+
375
+ // quaternions
376
+ let scale = [
377
+ f_buffer[8 * i + 3 + 0],
378
+ f_buffer[8 * i + 3 + 1],
379
+ f_buffer[8 * i + 3 + 2],
380
+ ];
381
+ let rot = [
382
+ (u_buffer[32 * i + 28 + 0] - 128) / 128,
383
+ (u_buffer[32 * i + 28 + 1] - 128) / 128,
384
+ (u_buffer[32 * i + 28 + 2] - 128) / 128,
385
+ (u_buffer[32 * i + 28 + 3] - 128) / 128,
386
+ ];
387
+
388
+ // Compute the matrix product of S and R (M = S * R)
389
+ const M = [
390
+ 1.0 - 2.0 * (rot[2] * rot[2] + rot[3] * rot[3]),
391
+ 2.0 * (rot[1] * rot[2] + rot[0] * rot[3]),
392
+ 2.0 * (rot[1] * rot[3] - rot[0] * rot[2]),
393
+
394
+ 2.0 * (rot[1] * rot[2] - rot[0] * rot[3]),
395
+ 1.0 - 2.0 * (rot[1] * rot[1] + rot[3] * rot[3]),
396
+ 2.0 * (rot[2] * rot[3] + rot[0] * rot[1]),
397
+
398
+ 2.0 * (rot[1] * rot[3] + rot[0] * rot[2]),
399
+ 2.0 * (rot[2] * rot[3] - rot[0] * rot[1]),
400
+ 1.0 - 2.0 * (rot[1] * rot[1] + rot[2] * rot[2]),
401
+ ].map((k, i) => k * scale[Math.floor(i / 3)]);
402
+
403
+ const sigma = [
404
+ M[0] * M[0] + M[3] * M[3] + M[6] * M[6],
405
+ M[0] * M[1] + M[3] * M[4] + M[6] * M[7],
406
+ M[0] * M[2] + M[3] * M[5] + M[6] * M[8],
407
+ M[1] * M[1] + M[4] * M[4] + M[7] * M[7],
408
+ M[1] * M[2] + M[4] * M[5] + M[7] * M[8],
409
+ M[2] * M[2] + M[5] * M[5] + M[8] * M[8],
410
+ ];
411
+
412
+ texdata[8 * i + 4] = packHalf2x16(4 * sigma[0], 4 * sigma[1]);
413
+ texdata[8 * i + 5] = packHalf2x16(4 * sigma[2], 4 * sigma[3]);
414
+ texdata[8 * i + 6] = packHalf2x16(4 * sigma[4], 4 * sigma[5]);
415
+ }
416
+
417
+ self.postMessage({ texdata, texwidth, texheight }, [texdata.buffer]);
418
+ }
419
+
420
+ function runSort(viewProj) {
421
+ if (!buffer) return;
422
+ const f_buffer = new Float32Array(buffer);
423
+ if (lastVertexCount == vertexCount) {
424
+ let dot =
425
+ lastProj[2] * viewProj[2] +
426
+ lastProj[6] * viewProj[6] +
427
+ lastProj[10] * viewProj[10];
428
+ if (Math.abs(dot - 1) < 0.01) {
429
+ return;
430
+ }
431
+ } else {
432
+ generateTexture();
433
+ lastVertexCount = vertexCount;
434
+ }
435
+
436
+ console.time("sort");
437
+ let maxDepth = -Infinity;
438
+ let minDepth = Infinity;
439
+ let sizeList = new Int32Array(vertexCount);
440
+ for (let i = 0; i < vertexCount; i++) {
441
+ let depth =
442
+ ((viewProj[2] * f_buffer[8 * i + 0] +
443
+ viewProj[6] * f_buffer[8 * i + 1] +
444
+ viewProj[10] * f_buffer[8 * i + 2]) *
445
+ 4096) |
446
+ 0;
447
+ sizeList[i] = depth;
448
+ if (depth > maxDepth) maxDepth = depth;
449
+ if (depth < minDepth) minDepth = depth;
450
+ }
451
+
452
+ // This is a 16 bit single-pass counting sort
453
+ let depthInv = (256 * 256 - 1) / (maxDepth - minDepth);
454
+ let counts0 = new Uint32Array(256 * 256);
455
+ for (let i = 0; i < vertexCount; i++) {
456
+ sizeList[i] = ((sizeList[i] - minDepth) * depthInv) | 0;
457
+ counts0[sizeList[i]]++;
458
+ }
459
+ let starts0 = new Uint32Array(256 * 256);
460
+ for (let i = 1; i < 256 * 256; i++)
461
+ starts0[i] = starts0[i - 1] + counts0[i - 1];
462
+ depthIndex = new Uint32Array(vertexCount);
463
+ for (let i = 0; i < vertexCount; i++)
464
+ depthIndex[starts0[sizeList[i]]++] = i;
465
+
466
+ console.timeEnd("sort");
467
+
468
+ lastProj = viewProj;
469
+ self.postMessage({ depthIndex, viewProj, vertexCount }, [
470
+ depthIndex.buffer,
471
+ ]);
472
+ }
473
+
474
+ function processPlyBuffer(inputBuffer) {
475
+ const ubuf = new Uint8Array(inputBuffer);
476
+ // 10KB ought to be enough for a header...
477
+ const header = new TextDecoder().decode(ubuf.slice(0, 1024 * 10));
478
+ const header_end = "end_header\n";
479
+ const header_end_index = header.indexOf(header_end);
480
+ if (header_end_index < 0)
481
+ throw new Error("Unable to read .ply file header");
482
+ const vertexCount = parseInt(/element vertex (\d+)\n/.exec(header)[1]);
483
+ console.log("Vertex Count", vertexCount);
484
+ let row_offset = 0,
485
+ offsets = {},
486
+ types = {};
487
+ const TYPE_MAP = {
488
+ double: "getFloat64",
489
+ int: "getInt32",
490
+ uint: "getUint32",
491
+ float: "getFloat32",
492
+ short: "getInt16",
493
+ ushort: "getUint16",
494
+ uchar: "getUint8",
495
+ };
496
+ for (let prop of header
497
+ .slice(0, header_end_index)
498
+ .split("\n")
499
+ .filter((k) => k.startsWith("property "))) {
500
+ const [p, type, name] = prop.split(" ");
501
+ const arrayType = TYPE_MAP[type] || "getInt8";
502
+ types[name] = arrayType;
503
+ offsets[name] = row_offset;
504
+ row_offset += parseInt(arrayType.replace(/[^\d]/g, "")) / 8;
505
+ }
506
+ console.log("Bytes per row", row_offset, types, offsets);
507
+
508
+ let dataView = new DataView(
509
+ inputBuffer,
510
+ header_end_index + header_end.length,
511
+ );
512
+ let row = 0;
513
+ const attrs = new Proxy(
514
+ {},
515
+ {
516
+ get(target, prop) {
517
+ if (!types[prop]) throw new Error(prop + " not found");
518
+ return dataView[types[prop]](
519
+ row * row_offset + offsets[prop],
520
+ true,
521
+ );
522
+ },
523
+ },
524
+ );
525
+
526
+ console.time("calculate importance");
527
+ let sizeList = new Float32Array(vertexCount);
528
+ let sizeIndex = new Uint32Array(vertexCount);
529
+ for (row = 0; row < vertexCount; row++) {
530
+ sizeIndex[row] = row;
531
+ if (!types["scale_0"]) continue;
532
+ const size =
533
+ Math.exp(attrs.scale_0) *
534
+ Math.exp(attrs.scale_1) *
535
+ Math.exp(attrs.scale_2);
536
+ const opacity = 1 / (1 + Math.exp(-attrs.opacity));
537
+ sizeList[row] = size * opacity;
538
+ }
539
+ console.timeEnd("calculate importance");
540
+
541
+ console.time("sort");
542
+ sizeIndex.sort((b, a) => sizeList[a] - sizeList[b]);
543
+ console.timeEnd("sort");
544
+
545
+ // 6*4 + 4 + 4 = 8*4
546
+ // XYZ - Position (Float32)
547
+ // XYZ - Scale (Float32)
548
+ // RGBA - colors (uint8)
549
+ // IJKL - quaternion/rot (uint8)
550
+ const rowLength = 3 * 4 + 3 * 4 + 4 + 4;
551
+ const buffer = new ArrayBuffer(rowLength * vertexCount);
552
+
553
+ console.time("build buffer");
554
+ for (let j = 0; j < vertexCount; j++) {
555
+ row = sizeIndex[j];
556
+
557
+ const position = new Float32Array(buffer, j * rowLength, 3);
558
+ const scales = new Float32Array(buffer, j * rowLength + 4 * 3, 3);
559
+ const rgba = new Uint8ClampedArray(
560
+ buffer,
561
+ j * rowLength + 4 * 3 + 4 * 3,
562
+ 4,
563
+ );
564
+ const rot = new Uint8ClampedArray(
565
+ buffer,
566
+ j * rowLength + 4 * 3 + 4 * 3 + 4,
567
+ 4,
568
+ );
569
+
570
+ if (types["scale_0"]) {
571
+ const qlen = Math.sqrt(
572
+ attrs.rot_0 ** 2 +
573
+ attrs.rot_1 ** 2 +
574
+ attrs.rot_2 ** 2 +
575
+ attrs.rot_3 ** 2,
576
+ );
577
+
578
+ rot[0] = (attrs.rot_0 / qlen) * 128 + 128;
579
+ rot[1] = (attrs.rot_1 / qlen) * 128 + 128;
580
+ rot[2] = (attrs.rot_2 / qlen) * 128 + 128;
581
+ rot[3] = (attrs.rot_3 / qlen) * 128 + 128;
582
+
583
+ scales[0] = Math.exp(attrs.scale_0);
584
+ scales[1] = Math.exp(attrs.scale_1);
585
+ scales[2] = Math.exp(attrs.scale_2);
586
+ } else {
587
+ scales[0] = 0.01;
588
+ scales[1] = 0.01;
589
+ scales[2] = 0.01;
590
+
591
+ rot[0] = 255;
592
+ rot[1] = 0;
593
+ rot[2] = 0;
594
+ rot[3] = 0;
595
+ }
596
+
597
+ position[0] = attrs.x;
598
+ position[1] = attrs.y;
599
+ position[2] = attrs.z;
600
+
601
+ if (types["f_dc_0"]) {
602
+ const SH_C0 = 0.28209479177387814;
603
+ rgba[0] = (0.5 + SH_C0 * attrs.f_dc_0) * 255;
604
+ rgba[1] = (0.5 + SH_C0 * attrs.f_dc_1) * 255;
605
+ rgba[2] = (0.5 + SH_C0 * attrs.f_dc_2) * 255;
606
+ } else {
607
+ rgba[0] = attrs.red;
608
+ rgba[1] = attrs.green;
609
+ rgba[2] = attrs.blue;
610
+ }
611
+ if (types["opacity"]) {
612
+ rgba[3] = (1 / (1 + Math.exp(-attrs.opacity))) * 255;
613
+ } else {
614
+ rgba[3] = 255;
615
+ }
616
+ }
617
+ console.timeEnd("build buffer");
618
+ return buffer;
619
+ }
620
+
621
+ const throttledSort = () => {
622
+ if (!sortRunning) {
623
+ sortRunning = true;
624
+ let lastView = viewProj;
625
+ runSort(lastView);
626
+ setTimeout(() => {
627
+ sortRunning = false;
628
+ if (lastView !== viewProj) {
629
+ throttledSort();
630
+ }
631
+ }, 0);
632
+ }
633
+ };
634
+
635
+ let sortRunning;
636
+ self.onmessage = (e) => {
637
+ if (e.data.ply) {
638
+ vertexCount = 0;
639
+ runSort(viewProj);
640
+ buffer = processPlyBuffer(e.data.ply);
641
+ vertexCount = Math.floor(buffer.byteLength / rowLength);
642
+ postMessage({ buffer: buffer, save: !!e.data.save });
643
+ } else if (e.data.buffer) {
644
+ buffer = e.data.buffer;
645
+ vertexCount = e.data.vertexCount;
646
+ } else if (e.data.vertexCount) {
647
+ vertexCount = e.data.vertexCount;
648
+ } else if (e.data.view) {
649
+ viewProj = e.data.view;
650
+ throttledSort();
651
+ }
652
+ };
653
+ }
654
+
655
+ const vertexShaderSource = `
656
+ #version 300 es
657
+ precision highp float;
658
+ precision highp int;
659
+
660
+ uniform highp usampler2D u_texture;
661
+ uniform mat4 projection, view;
662
+ uniform vec2 focal;
663
+ uniform vec2 viewport;
664
+
665
+ in vec2 position;
666
+ in int index;
667
+
668
+ out vec4 vColor;
669
+ out vec2 vPosition;
670
+
671
+ void main () {
672
+ uvec4 cen = texelFetch(u_texture, ivec2((uint(index) & 0x3ffu) << 1, uint(index) >> 10), 0);
673
+ vec4 cam = view * vec4(uintBitsToFloat(cen.xyz), 1);
674
+ vec4 pos2d = projection * cam;
675
+
676
+ float clip = 1.2 * pos2d.w;
677
+ if (pos2d.z < -clip || pos2d.x < -clip || pos2d.x > clip || pos2d.y < -clip || pos2d.y > clip) {
678
+ gl_Position = vec4(0.0, 0.0, 2.0, 1.0);
679
+ return;
680
+ }
681
+
682
+ uvec4 cov = texelFetch(u_texture, ivec2(((uint(index) & 0x3ffu) << 1) | 1u, uint(index) >> 10), 0);
683
+ vec2 u1 = unpackHalf2x16(cov.x), u2 = unpackHalf2x16(cov.y), u3 = unpackHalf2x16(cov.z);
684
+ mat3 Vrk = mat3(u1.x, u1.y, u2.x, u1.y, u2.y, u3.x, u2.x, u3.x, u3.y);
685
+
686
+ mat3 J = mat3(
687
+ focal.x / cam.z, 0., -(focal.x * cam.x) / (cam.z * cam.z),
688
+ 0., -focal.y / cam.z, (focal.y * cam.y) / (cam.z * cam.z),
689
+ 0., 0., 0.
690
+ );
691
+
692
+ mat3 T = transpose(mat3(view)) * J;
693
+ mat3 cov2d = transpose(T) * Vrk * T;
694
+
695
+ float mid = (cov2d[0][0] + cov2d[1][1]) / 2.0;
696
+ float radius = length(vec2((cov2d[0][0] - cov2d[1][1]) / 2.0, cov2d[0][1]));
697
+ float lambda1 = mid + radius, lambda2 = mid - radius;
698
+
699
+ if(lambda2 < 0.0) return;
700
+ vec2 diagonalVector = normalize(vec2(cov2d[0][1], lambda1 - cov2d[0][0]));
701
+ vec2 majorAxis = min(sqrt(2.0 * lambda1), 1024.0) * diagonalVector;
702
+ vec2 minorAxis = min(sqrt(2.0 * lambda2), 1024.0) * vec2(diagonalVector.y, -diagonalVector.x);
703
+
704
+ vColor = clamp(pos2d.z/pos2d.w+1.0, 0.0, 1.0) * vec4((cov.w) & 0xffu, (cov.w >> 8) & 0xffu, (cov.w >> 16) & 0xffu, (cov.w >> 24) & 0xffu) / 255.0;
705
+ vPosition = position;
706
+
707
+ vec2 vCenter = vec2(pos2d) / pos2d.w;
708
+ gl_Position = vec4(
709
+ vCenter
710
+ + position.x * majorAxis / viewport
711
+ + position.y * minorAxis / viewport, 0.0, 1.0);
712
+
713
+ }
714
+ `.trim();
715
+
716
+ const fragmentShaderSource = `
717
+ #version 300 es
718
+ precision highp float;
719
+
720
+ in vec4 vColor;
721
+ in vec2 vPosition;
722
+
723
+ out vec4 fragColor;
724
+
725
+ void main () {
726
+ float A = -dot(vPosition, vPosition);
727
+ if (A < -4.0) discard;
728
+ float B = exp(A) * vColor.a;
729
+ fragColor = vec4(B * vColor.rgb, B);
730
+ }
731
+
732
+ `.trim();
733
+
734
+ let defaultViewMatrix = [
735
+ 0.47, 0.04, 0.88, 0, -0.11, 0.99, 0.02, 0, -0.88, -0.11, 0.47, 0, 0.07,
736
+ 0.03, 6.55, 1,
737
+ ];
738
+ let viewMatrix = defaultViewMatrix;
739
+ async function main() {
740
+ let carousel = true;
741
+ const params = new URLSearchParams(location.search);
742
+ try {
743
+ viewMatrix = JSON.parse(decodeURIComponent(location.hash.slice(1)));
744
+ carousel = false;
745
+ } catch (err) {}
746
+ const url = new URL(
747
+ // "nike.splat",
748
+ // location.href,
749
+ params.get("url") || "train.splat",
750
+ "https://huggingface.co/cakewalk/splat-data/resolve/main/",
751
+ );
752
+ const req = await fetch(url, {
753
+ mode: "cors", // no-cors, *cors, same-origin
754
+ credentials: "omit", // include, *same-origin, omit
755
+ });
756
+ console.log(req);
757
+ if (req.status != 200)
758
+ throw new Error(req.status + " Unable to load " + req.url);
759
+
760
+ const rowLength = 3 * 4 + 3 * 4 + 4 + 4;
761
+ const reader = req.body.getReader();
762
+ let splatData = new Uint8Array(req.headers.get("content-length"));
763
+
764
+ const downsample =
765
+ splatData.length / rowLength > 500000 ? 1 : 1 / devicePixelRatio;
766
+ console.log(splatData.length / rowLength, downsample);
767
+
768
+ const worker = new Worker(
769
+ URL.createObjectURL(
770
+ new Blob(["(", createWorker.toString(), ")(self)"], {
771
+ type: "application/javascript",
772
+ }),
773
+ ),
774
+ );
775
+
776
+ const canvas = document.getElementById("canvas");
777
+ const fps = document.getElementById("fps");
778
+ const camid = document.getElementById("camid");
779
+
780
+ let projectionMatrix;
781
+
782
+ const gl = canvas.getContext("webgl2", {
783
+ antialias: false,
784
+ });
785
+
786
+ const vertexShader = gl.createShader(gl.VERTEX_SHADER);
787
+ gl.shaderSource(vertexShader, vertexShaderSource);
788
+ gl.compileShader(vertexShader);
789
+ if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS))
790
+ console.error(gl.getShaderInfoLog(vertexShader));
791
+
792
+ const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
793
+ gl.shaderSource(fragmentShader, fragmentShaderSource);
794
+ gl.compileShader(fragmentShader);
795
+ if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS))
796
+ console.error(gl.getShaderInfoLog(fragmentShader));
797
+
798
+ const program = gl.createProgram();
799
+ gl.attachShader(program, vertexShader);
800
+ gl.attachShader(program, fragmentShader);
801
+ gl.linkProgram(program);
802
+ gl.useProgram(program);
803
+
804
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS))
805
+ console.error(gl.getProgramInfoLog(program));
806
+
807
+ gl.disable(gl.DEPTH_TEST); // Disable depth testing
808
+
809
+ // Enable blending
810
+ gl.enable(gl.BLEND);
811
+ gl.blendFuncSeparate(
812
+ gl.ONE_MINUS_DST_ALPHA,
813
+ gl.ONE,
814
+ gl.ONE_MINUS_DST_ALPHA,
815
+ gl.ONE,
816
+ );
817
+ gl.blendEquationSeparate(gl.FUNC_ADD, gl.FUNC_ADD);
818
+
819
+ const u_projection = gl.getUniformLocation(program, "projection");
820
+ const u_viewport = gl.getUniformLocation(program, "viewport");
821
+ const u_focal = gl.getUniformLocation(program, "focal");
822
+ const u_view = gl.getUniformLocation(program, "view");
823
+
824
+ // positions
825
+ const triangleVertices = new Float32Array([-2, -2, 2, -2, 2, 2, -2, 2]);
826
+ const vertexBuffer = gl.createBuffer();
827
+ gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
828
+ gl.bufferData(gl.ARRAY_BUFFER, triangleVertices, gl.STATIC_DRAW);
829
+ const a_position = gl.getAttribLocation(program, "position");
830
+ gl.enableVertexAttribArray(a_position);
831
+ gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
832
+ gl.vertexAttribPointer(a_position, 2, gl.FLOAT, false, 0, 0);
833
+
834
+ var texture = gl.createTexture();
835
+ gl.bindTexture(gl.TEXTURE_2D, texture);
836
+
837
+ var u_textureLocation = gl.getUniformLocation(program, "u_texture");
838
+ gl.uniform1i(u_textureLocation, 0);
839
+
840
+ const indexBuffer = gl.createBuffer();
841
+ const a_index = gl.getAttribLocation(program, "index");
842
+ gl.enableVertexAttribArray(a_index);
843
+ gl.bindBuffer(gl.ARRAY_BUFFER, indexBuffer);
844
+ gl.vertexAttribIPointer(a_index, 1, gl.INT, false, 0, 0);
845
+ gl.vertexAttribDivisor(a_index, 1);
846
+
847
+ const resize = () => {
848
+ gl.uniform2fv(u_focal, new Float32Array([camera.fx, camera.fy]));
849
+
850
+ projectionMatrix = getProjectionMatrix(
851
+ camera.fx,
852
+ camera.fy,
853
+ innerWidth,
854
+ innerHeight,
855
+ );
856
+
857
+ gl.uniform2fv(u_viewport, new Float32Array([innerWidth, innerHeight]));
858
+
859
+ gl.canvas.width = Math.round(innerWidth / downsample);
860
+ gl.canvas.height = Math.round(innerHeight / downsample);
861
+ gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
862
+
863
+ gl.uniformMatrix4fv(u_projection, false, projectionMatrix);
864
+ };
865
+
866
+ window.addEventListener("resize", resize);
867
+ resize();
868
+
869
+ worker.onmessage = (e) => {
870
+ if (e.data.buffer) {
871
+ splatData = new Uint8Array(e.data.buffer);
872
+ if (e.data.save) {
873
+ const blob = new Blob([splatData.buffer], {
874
+ type: "application/octet-stream",
875
+ });
876
+ const link = document.createElement("a");
877
+ link.download = "model.splat";
878
+ link.href = URL.createObjectURL(blob);
879
+ document.body.appendChild(link);
880
+ link.click();
881
+ }
882
+ } else if (e.data.texdata) {
883
+ const { texdata, texwidth, texheight } = e.data;
884
+ // console.log(texdata)
885
+ gl.bindTexture(gl.TEXTURE_2D, texture);
886
+ gl.texParameteri(
887
+ gl.TEXTURE_2D,
888
+ gl.TEXTURE_WRAP_S,
889
+ gl.CLAMP_TO_EDGE,
890
+ );
891
+ gl.texParameteri(
892
+ gl.TEXTURE_2D,
893
+ gl.TEXTURE_WRAP_T,
894
+ gl.CLAMP_TO_EDGE,
895
+ );
896
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
897
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
898
+
899
+ gl.texImage2D(
900
+ gl.TEXTURE_2D,
901
+ 0,
902
+ gl.RGBA32UI,
903
+ texwidth,
904
+ texheight,
905
+ 0,
906
+ gl.RGBA_INTEGER,
907
+ gl.UNSIGNED_INT,
908
+ texdata,
909
+ );
910
+ gl.activeTexture(gl.TEXTURE0);
911
+ gl.bindTexture(gl.TEXTURE_2D, texture);
912
+ } else if (e.data.depthIndex) {
913
+ const { depthIndex, viewProj } = e.data;
914
+ gl.bindBuffer(gl.ARRAY_BUFFER, indexBuffer);
915
+ gl.bufferData(gl.ARRAY_BUFFER, depthIndex, gl.DYNAMIC_DRAW);
916
+ vertexCount = e.data.vertexCount;
917
+ }
918
+ };
919
+
920
+ let activeKeys = [];
921
+ let currentCameraIndex = 0;
922
+
923
+ window.addEventListener("keydown", (e) => {
924
+ // if (document.activeElement != document.body) return;
925
+ carousel = false;
926
+ if (!activeKeys.includes(e.code)) activeKeys.push(e.code);
927
+ if (/\d/.test(e.key)) {
928
+ currentCameraIndex = parseInt(e.key);
929
+ camera = cameras[currentCameraIndex];
930
+ viewMatrix = getViewMatrix(camera);
931
+ }
932
+ if (["-", "_"].includes(e.key)) {
933
+ currentCameraIndex =
934
+ (currentCameraIndex + cameras.length - 1) % cameras.length;
935
+ viewMatrix = getViewMatrix(cameras[currentCameraIndex]);
936
+ }
937
+ if (["+", "="].includes(e.key)) {
938
+ currentCameraIndex = (currentCameraIndex + 1) % cameras.length;
939
+ viewMatrix = getViewMatrix(cameras[currentCameraIndex]);
940
+ }
941
+ camid.innerText = "cam " + currentCameraIndex;
942
+ if (e.code == "KeyV") {
943
+ location.hash =
944
+ "#" +
945
+ JSON.stringify(
946
+ viewMatrix.map((k) => Math.round(k * 100) / 100),
947
+ );
948
+ camid.innerText = "";
949
+ } else if (e.code === "KeyP") {
950
+ carousel = true;
951
+ camid.innerText = "";
952
+ }
953
+ });
954
+ window.addEventListener("keyup", (e) => {
955
+ activeKeys = activeKeys.filter((k) => k !== e.code);
956
+ });
957
+ window.addEventListener("blur", () => {
958
+ activeKeys = [];
959
+ });
960
+
961
+ window.addEventListener(
962
+ "wheel",
963
+ (e) => {
964
+ carousel = false;
965
+ e.preventDefault();
966
+ const lineHeight = 10;
967
+ const scale =
968
+ e.deltaMode == 1
969
+ ? lineHeight
970
+ : e.deltaMode == 2
971
+ ? innerHeight
972
+ : 1;
973
+ let inv = invert4(viewMatrix);
974
+ if (e.shiftKey) {
975
+ inv = translate4(
976
+ inv,
977
+ (e.deltaX * scale) / innerWidth,
978
+ (e.deltaY * scale) / innerHeight,
979
+ 0,
980
+ );
981
+ } else if (e.ctrlKey || e.metaKey) {
982
+ // inv = rotate4(inv, (e.deltaX * scale) / innerWidth, 0, 0, 1);
983
+ // inv = translate4(inv, 0, (e.deltaY * scale) / innerHeight, 0);
984
+ // let preY = inv[13];
985
+ inv = translate4(
986
+ inv,
987
+ 0,
988
+ 0,
989
+ (-10 * (e.deltaY * scale)) / innerHeight,
990
+ );
991
+ // inv[13] = preY;
992
+ } else {
993
+ let d = 4;
994
+ inv = translate4(inv, 0, 0, d);
995
+ inv = rotate4(inv, -(e.deltaX * scale) / innerWidth, 0, 1, 0);
996
+ inv = rotate4(inv, (e.deltaY * scale) / innerHeight, 1, 0, 0);
997
+ inv = translate4(inv, 0, 0, -d);
998
+ }
999
+
1000
+ viewMatrix = invert4(inv);
1001
+ },
1002
+ { passive: false },
1003
+ );
1004
+
1005
+ let startX, startY, down;
1006
+ canvas.addEventListener("mousedown", (e) => {
1007
+ carousel = false;
1008
+ e.preventDefault();
1009
+ startX = e.clientX;
1010
+ startY = e.clientY;
1011
+ down = e.ctrlKey || e.metaKey ? 2 : 1;
1012
+ });
1013
+ canvas.addEventListener("contextmenu", (e) => {
1014
+ carousel = false;
1015
+ e.preventDefault();
1016
+ startX = e.clientX;
1017
+ startY = e.clientY;
1018
+ down = 2;
1019
+ });
1020
+
1021
+ canvas.addEventListener("mousemove", (e) => {
1022
+ e.preventDefault();
1023
+ if (down == 1) {
1024
+ let inv = invert4(viewMatrix);
1025
+ let dx = (5 * (e.clientX - startX)) / innerWidth;
1026
+ let dy = (5 * (e.clientY - startY)) / innerHeight;
1027
+ let d = 4;
1028
+
1029
+ inv = translate4(inv, 0, 0, d);
1030
+ inv = rotate4(inv, dx, 0, 1, 0);
1031
+ inv = rotate4(inv, -dy, 1, 0, 0);
1032
+ inv = translate4(inv, 0, 0, -d);
1033
+ // let postAngle = Math.atan2(inv[0], inv[10])
1034
+ // inv = rotate4(inv, postAngle - preAngle, 0, 0, 1)
1035
+ // console.log(postAngle)
1036
+ viewMatrix = invert4(inv);
1037
+
1038
+ startX = e.clientX;
1039
+ startY = e.clientY;
1040
+ } else if (down == 2) {
1041
+ let inv = invert4(viewMatrix);
1042
+ // inv = rotateY(inv, );
1043
+ // let preY = inv[13];
1044
+ inv = translate4(
1045
+ inv,
1046
+ (-10 * (e.clientX - startX)) / innerWidth,
1047
+ 0,
1048
+ (10 * (e.clientY - startY)) / innerHeight,
1049
+ );
1050
+ // inv[13] = preY;
1051
+ viewMatrix = invert4(inv);
1052
+
1053
+ startX = e.clientX;
1054
+ startY = e.clientY;
1055
+ }
1056
+ });
1057
+ canvas.addEventListener("mouseup", (e) => {
1058
+ e.preventDefault();
1059
+ down = false;
1060
+ startX = 0;
1061
+ startY = 0;
1062
+ });
1063
+
1064
+ let altX = 0,
1065
+ altY = 0;
1066
+ canvas.addEventListener(
1067
+ "touchstart",
1068
+ (e) => {
1069
+ e.preventDefault();
1070
+ if (e.touches.length === 1) {
1071
+ carousel = false;
1072
+ startX = e.touches[0].clientX;
1073
+ startY = e.touches[0].clientY;
1074
+ down = 1;
1075
+ } else if (e.touches.length === 2) {
1076
+ // console.log('beep')
1077
+ carousel = false;
1078
+ startX = e.touches[0].clientX;
1079
+ altX = e.touches[1].clientX;
1080
+ startY = e.touches[0].clientY;
1081
+ altY = e.touches[1].clientY;
1082
+ down = 1;
1083
+ }
1084
+ },
1085
+ { passive: false },
1086
+ );
1087
+ canvas.addEventListener(
1088
+ "touchmove",
1089
+ (e) => {
1090
+ e.preventDefault();
1091
+ if (e.touches.length === 1 && down) {
1092
+ let inv = invert4(viewMatrix);
1093
+ let dx = (4 * (e.touches[0].clientX - startX)) / innerWidth;
1094
+ let dy = (4 * (e.touches[0].clientY - startY)) / innerHeight;
1095
+
1096
+ let d = 4;
1097
+ inv = translate4(inv, 0, 0, d);
1098
+ // inv = translate4(inv, -x, -y, -z);
1099
+ // inv = translate4(inv, x, y, z);
1100
+ inv = rotate4(inv, dx, 0, 1, 0);
1101
+ inv = rotate4(inv, -dy, 1, 0, 0);
1102
+ inv = translate4(inv, 0, 0, -d);
1103
+
1104
+ viewMatrix = invert4(inv);
1105
+
1106
+ startX = e.touches[0].clientX;
1107
+ startY = e.touches[0].clientY;
1108
+ } else if (e.touches.length === 2) {
1109
+ // alert('beep')
1110
+ const dtheta =
1111
+ Math.atan2(startY - altY, startX - altX) -
1112
+ Math.atan2(
1113
+ e.touches[0].clientY - e.touches[1].clientY,
1114
+ e.touches[0].clientX - e.touches[1].clientX,
1115
+ );
1116
+ const dscale =
1117
+ Math.hypot(startX - altX, startY - altY) /
1118
+ Math.hypot(
1119
+ e.touches[0].clientX - e.touches[1].clientX,
1120
+ e.touches[0].clientY - e.touches[1].clientY,
1121
+ );
1122
+ const dx =
1123
+ (e.touches[0].clientX +
1124
+ e.touches[1].clientX -
1125
+ (startX + altX)) /
1126
+ 2;
1127
+ const dy =
1128
+ (e.touches[0].clientY +
1129
+ e.touches[1].clientY -
1130
+ (startY + altY)) /
1131
+ 2;
1132
+ let inv = invert4(viewMatrix);
1133
+ // inv = translate4(inv, 0, 0, d);
1134
+ inv = rotate4(inv, dtheta, 0, 0, 1);
1135
+
1136
+ inv = translate4(inv, -dx / innerWidth, -dy / innerHeight, 0);
1137
+
1138
+ // let preY = inv[13];
1139
+ inv = translate4(inv, 0, 0, 3 * (1 - dscale));
1140
+ // inv[13] = preY;
1141
+
1142
+ viewMatrix = invert4(inv);
1143
+
1144
+ startX = e.touches[0].clientX;
1145
+ altX = e.touches[1].clientX;
1146
+ startY = e.touches[0].clientY;
1147
+ altY = e.touches[1].clientY;
1148
+ }
1149
+ },
1150
+ { passive: false },
1151
+ );
1152
+ canvas.addEventListener(
1153
+ "touchend",
1154
+ (e) => {
1155
+ e.preventDefault();
1156
+ down = false;
1157
+ startX = 0;
1158
+ startY = 0;
1159
+ },
1160
+ { passive: false },
1161
+ );
1162
+
1163
+ let jumpDelta = 0;
1164
+ let vertexCount = 0;
1165
+
1166
+ let lastFrame = 0;
1167
+ let avgFps = 0;
1168
+ let start = 0;
1169
+
1170
+ window.addEventListener("gamepadconnected", (e) => {
1171
+ const gp = navigator.getGamepads()[e.gamepad.index];
1172
+ console.log(
1173
+ `Gamepad connected at index ${gp.index}: ${gp.id}. It has ${gp.buttons.length} buttons and ${gp.axes.length} axes.`,
1174
+ );
1175
+ });
1176
+ window.addEventListener("gamepaddisconnected", (e) => {
1177
+ console.log("Gamepad disconnected");
1178
+ });
1179
+
1180
+ let leftGamepadTrigger, rightGamepadTrigger;
1181
+
1182
+ const frame = (now) => {
1183
+ let inv = invert4(viewMatrix);
1184
+ let shiftKey =
1185
+ activeKeys.includes("Shift") ||
1186
+ activeKeys.includes("ShiftLeft") ||
1187
+ activeKeys.includes("ShiftRight");
1188
+
1189
+ if (activeKeys.includes("ArrowUp")) {
1190
+ if (shiftKey) {
1191
+ inv = translate4(inv, 0, -0.03, 0);
1192
+ } else {
1193
+ inv = translate4(inv, 0, 0, 0.1);
1194
+ }
1195
+ }
1196
+ if (activeKeys.includes("ArrowDown")) {
1197
+ if (shiftKey) {
1198
+ inv = translate4(inv, 0, 0.03, 0);
1199
+ } else {
1200
+ inv = translate4(inv, 0, 0, -0.1);
1201
+ }
1202
+ }
1203
+ if (activeKeys.includes("ArrowLeft"))
1204
+ inv = translate4(inv, -0.03, 0, 0);
1205
+ //
1206
+ if (activeKeys.includes("ArrowRight"))
1207
+ inv = translate4(inv, 0.03, 0, 0);
1208
+ // inv = rotate4(inv, 0.01, 0, 1, 0);
1209
+ if (activeKeys.includes("KeyA")) inv = rotate4(inv, -0.01, 0, 1, 0);
1210
+ if (activeKeys.includes("KeyD")) inv = rotate4(inv, 0.01, 0, 1, 0);
1211
+ if (activeKeys.includes("KeyQ")) inv = rotate4(inv, 0.01, 0, 0, 1);
1212
+ if (activeKeys.includes("KeyE")) inv = rotate4(inv, -0.01, 0, 0, 1);
1213
+ if (activeKeys.includes("KeyW")) inv = rotate4(inv, 0.005, 1, 0, 0);
1214
+ if (activeKeys.includes("KeyS")) inv = rotate4(inv, -0.005, 1, 0, 0);
1215
+
1216
+ const gamepads = navigator.getGamepads ? navigator.getGamepads() : [];
1217
+ let isJumping = activeKeys.includes("Space");
1218
+ for (let gamepad of gamepads) {
1219
+ if (!gamepad) continue;
1220
+
1221
+ const axisThreshold = 0.1; // Threshold to detect when the axis is intentionally moved
1222
+ const moveSpeed = 0.06;
1223
+ const rotateSpeed = 0.02;
1224
+
1225
+ // Assuming the left stick controls translation (axes 0 and 1)
1226
+ if (Math.abs(gamepad.axes[0]) > axisThreshold) {
1227
+ inv = translate4(inv, moveSpeed * gamepad.axes[0], 0, 0);
1228
+ carousel = false;
1229
+ }
1230
+ if (Math.abs(gamepad.axes[1]) > axisThreshold) {
1231
+ inv = translate4(inv, 0, 0, -moveSpeed * gamepad.axes[1]);
1232
+ carousel = false;
1233
+ }
1234
+ if (gamepad.buttons[12].pressed || gamepad.buttons[13].pressed) {
1235
+ inv = translate4(
1236
+ inv,
1237
+ 0,
1238
+ -moveSpeed *
1239
+ (gamepad.buttons[12].pressed -
1240
+ gamepad.buttons[13].pressed),
1241
+ 0,
1242
+ );
1243
+ carousel = false;
1244
+ }
1245
+
1246
+ if (gamepad.buttons[14].pressed || gamepad.buttons[15].pressed) {
1247
+ inv = translate4(
1248
+ inv,
1249
+ -moveSpeed *
1250
+ (gamepad.buttons[14].pressed -
1251
+ gamepad.buttons[15].pressed),
1252
+ 0,
1253
+ 0,
1254
+ );
1255
+ carousel = false;
1256
+ }
1257
+
1258
+ // Assuming the right stick controls rotation (axes 2 and 3)
1259
+ if (Math.abs(gamepad.axes[2]) > axisThreshold) {
1260
+ inv = rotate4(inv, rotateSpeed * gamepad.axes[2], 0, 1, 0);
1261
+ carousel = false;
1262
+ }
1263
+ if (Math.abs(gamepad.axes[3]) > axisThreshold) {
1264
+ inv = rotate4(inv, -rotateSpeed * gamepad.axes[3], 1, 0, 0);
1265
+ carousel = false;
1266
+ }
1267
+
1268
+ let tiltAxis = gamepad.buttons[6].value - gamepad.buttons[7].value;
1269
+ if (Math.abs(tiltAxis) > axisThreshold) {
1270
+ inv = rotate4(inv, rotateSpeed * tiltAxis, 0, 0, 1);
1271
+ carousel = false;
1272
+ }
1273
+ if (gamepad.buttons[4].pressed && !leftGamepadTrigger) {
1274
+ camera =
1275
+ cameras[(cameras.indexOf(camera) + 1) % cameras.length];
1276
+ inv = invert4(getViewMatrix(camera));
1277
+ carousel = false;
1278
+ }
1279
+ if (gamepad.buttons[5].pressed && !rightGamepadTrigger) {
1280
+ camera =
1281
+ cameras[
1282
+ (cameras.indexOf(camera) + cameras.length - 1) %
1283
+ cameras.length
1284
+ ];
1285
+ inv = invert4(getViewMatrix(camera));
1286
+ carousel = false;
1287
+ }
1288
+ leftGamepadTrigger = gamepad.buttons[4].pressed;
1289
+ rightGamepadTrigger = gamepad.buttons[5].pressed;
1290
+ if (gamepad.buttons[0].pressed) {
1291
+ isJumping = true;
1292
+ carousel = false;
1293
+ }
1294
+ if (gamepad.buttons[3].pressed) {
1295
+ carousel = true;
1296
+ }
1297
+ }
1298
+
1299
+ if (
1300
+ ["KeyJ", "KeyK", "KeyL", "KeyI"].some((k) => activeKeys.includes(k))
1301
+ ) {
1302
+ let d = 4;
1303
+ inv = translate4(inv, 0, 0, d);
1304
+ inv = rotate4(
1305
+ inv,
1306
+ activeKeys.includes("KeyJ")
1307
+ ? -0.05
1308
+ : activeKeys.includes("KeyL")
1309
+ ? 0.05
1310
+ : 0,
1311
+ 0,
1312
+ 1,
1313
+ 0,
1314
+ );
1315
+ inv = rotate4(
1316
+ inv,
1317
+ activeKeys.includes("KeyI")
1318
+ ? 0.05
1319
+ : activeKeys.includes("KeyK")
1320
+ ? -0.05
1321
+ : 0,
1322
+ 1,
1323
+ 0,
1324
+ 0,
1325
+ );
1326
+ inv = translate4(inv, 0, 0, -d);
1327
+ }
1328
+
1329
+ viewMatrix = invert4(inv);
1330
+
1331
+ if (carousel) {
1332
+ let inv = invert4(defaultViewMatrix);
1333
+
1334
+ const t = Math.sin((Date.now() - start) / 5000);
1335
+ inv = translate4(inv, 2.5 * t, 0, 6 * (1 - Math.cos(t)));
1336
+ inv = rotate4(inv, -0.6 * t, 0, 1, 0);
1337
+
1338
+ viewMatrix = invert4(inv);
1339
+ }
1340
+
1341
+ if (isJumping) {
1342
+ jumpDelta = Math.min(1, jumpDelta + 0.05);
1343
+ } else {
1344
+ jumpDelta = Math.max(0, jumpDelta - 0.05);
1345
+ }
1346
+
1347
+ let inv2 = invert4(viewMatrix);
1348
+ inv2 = translate4(inv2, 0, -jumpDelta, 0);
1349
+ inv2 = rotate4(inv2, -0.1 * jumpDelta, 1, 0, 0);
1350
+ let actualViewMatrix = invert4(inv2);
1351
+
1352
+ const viewProj = multiply4(projectionMatrix, actualViewMatrix);
1353
+ worker.postMessage({ view: viewProj });
1354
+
1355
+ const currentFps = 1000 / (now - lastFrame) || 0;
1356
+ avgFps = avgFps * 0.9 + currentFps * 0.1;
1357
+
1358
+ if (vertexCount > 0) {
1359
+ document.getElementById("spinner").style.display = "none";
1360
+ gl.uniformMatrix4fv(u_view, false, actualViewMatrix);
1361
+ gl.clear(gl.COLOR_BUFFER_BIT);
1362
+ gl.drawArraysInstanced(gl.TRIANGLE_FAN, 0, 4, vertexCount);
1363
+ } else {
1364
+ gl.clear(gl.COLOR_BUFFER_BIT);
1365
+ document.getElementById("spinner").style.display = "";
1366
+ start = Date.now() + 2000;
1367
+ }
1368
+ const progress = (100 * vertexCount) / (splatData.length / rowLength);
1369
+ if (progress < 100) {
1370
+ document.getElementById("progress").style.width = progress + "%";
1371
+ } else {
1372
+ document.getElementById("progress").style.display = "none";
1373
+ }
1374
+ fps.innerText = Math.round(avgFps) + " fps";
1375
+ if (isNaN(currentCameraIndex)) {
1376
+ camid.innerText = "";
1377
+ }
1378
+ lastFrame = now;
1379
+ requestAnimationFrame(frame);
1380
+ };
1381
+
1382
+ frame();
1383
+
1384
+ const isPly = (splatData) =>
1385
+ splatData[0] == 112 &&
1386
+ splatData[1] == 108 &&
1387
+ splatData[2] == 121 &&
1388
+ splatData[3] == 10;
1389
+
1390
+ const selectFile = (file) => {
1391
+ const fr = new FileReader();
1392
+ if (/\.json$/i.test(file.name)) {
1393
+ fr.onload = () => {
1394
+ cameras = JSON.parse(fr.result);
1395
+ viewMatrix = getViewMatrix(cameras[0]);
1396
+ projectionMatrix = getProjectionMatrix(
1397
+ camera.fx / downsample,
1398
+ camera.fy / downsample,
1399
+ canvas.width,
1400
+ canvas.height,
1401
+ );
1402
+ gl.uniformMatrix4fv(u_projection, false, projectionMatrix);
1403
+
1404
+ console.log("Loaded Cameras");
1405
+ };
1406
+ fr.readAsText(file);
1407
+ } else {
1408
+ stopLoading = true;
1409
+ fr.onload = () => {
1410
+ splatData = new Uint8Array(fr.result);
1411
+ console.log("Loaded", Math.floor(splatData.length / rowLength));
1412
+
1413
+ if (isPly(splatData)) {
1414
+ // ply file magic header means it should be handled differently
1415
+ worker.postMessage({ ply: splatData.buffer, save: true });
1416
+ } else {
1417
+ worker.postMessage({
1418
+ buffer: splatData.buffer,
1419
+ vertexCount: Math.floor(splatData.length / rowLength),
1420
+ });
1421
+ }
1422
+ };
1423
+ fr.readAsArrayBuffer(file);
1424
+ }
1425
+ };
1426
+
1427
+ window.addEventListener("hashchange", (e) => {
1428
+ try {
1429
+ viewMatrix = JSON.parse(decodeURIComponent(location.hash.slice(1)));
1430
+ carousel = false;
1431
+ } catch (err) {}
1432
+ });
1433
+
1434
+ const preventDefault = (e) => {
1435
+ e.preventDefault();
1436
+ e.stopPropagation();
1437
+ };
1438
+ document.addEventListener("dragenter", preventDefault);
1439
+ document.addEventListener("dragover", preventDefault);
1440
+ document.addEventListener("dragleave", preventDefault);
1441
+ document.addEventListener("drop", (e) => {
1442
+ e.preventDefault();
1443
+ e.stopPropagation();
1444
+ selectFile(e.dataTransfer.files[0]);
1445
+ });
1446
+
1447
+ let bytesRead = 0;
1448
+ let lastVertexCount = -1;
1449
+ let stopLoading = false;
1450
+
1451
+ while (true) {
1452
+ const { done, value } = await reader.read();
1453
+ if (done || stopLoading) break;
1454
+
1455
+ splatData.set(value, bytesRead);
1456
+ bytesRead += value.length;
1457
+
1458
+ if (vertexCount > lastVertexCount) {
1459
+ if (!isPly(splatData)) {
1460
+ worker.postMessage({
1461
+ buffer: splatData.buffer,
1462
+ vertexCount: Math.floor(bytesRead / rowLength),
1463
+ });
1464
+ }
1465
+ lastVertexCount = vertexCount;
1466
+ }
1467
+ }
1468
+ if (!stopLoading) {
1469
+ if (isPly(splatData)) {
1470
+ // ply file magic header means it should be handled differently
1471
+ worker.postMessage({ ply: splatData.buffer, save: false });
1472
+ } else {
1473
+ worker.postMessage({
1474
+ buffer: splatData.buffer,
1475
+ vertexCount: Math.floor(bytesRead / rowLength),
1476
+ });
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ main().catch((err) => {
1482
+ document.getElementById("spinner").style.display = "none";
1483
+ document.getElementById("message").innerText = err.toString();
1484
+ });
splat_viewer.html ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" dir="ltr">
3
+ <head>
4
+ <title>WebGL Gaussian Splat Viewer</title>
5
+ <meta charset="utf-8" />
6
+ <meta
7
+ name="viewport"
8
+ content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"
9
+ />
10
+ <meta name="apple-mobile-web-app-capable" content="yes" />
11
+ <meta
12
+ name="apple-mobile-web-app-status-bar-style"
13
+ content="black-translucent"
14
+ />
15
+ <style>
16
+ body {
17
+ overflow: hidden;
18
+ margin: 0;
19
+ height: 100vh;
20
+ width: 100vw;
21
+ font-family: sans-serif;
22
+ background: black;
23
+ text-shadow: 0 0 3px black;
24
+ }
25
+ a, body {
26
+ color: white;
27
+ }
28
+ #info {
29
+ z-index: 100;
30
+ position: absolute;
31
+ top: 10px;
32
+ left: 15px;
33
+ }
34
+ h3 {
35
+ margin: 5px 0;
36
+ }
37
+ p {
38
+ margin: 5px 0;
39
+ font-size: small;
40
+ }
41
+
42
+ .cube-wrapper {
43
+ transform-style: preserve-3d;
44
+ }
45
+
46
+ .cube {
47
+ transform-style: preserve-3d;
48
+ transform: rotateX(45deg) rotateZ(45deg);
49
+ animation: rotation 2s infinite;
50
+ }
51
+
52
+ .cube-faces {
53
+ transform-style: preserve-3d;
54
+ height: 80px;
55
+ width: 80px;
56
+ position: relative;
57
+ transform-origin: 0 0;
58
+ transform: translateX(0) translateY(0) translateZ(-40px);
59
+ }
60
+
61
+ .cube-face {
62
+ position: absolute;
63
+ inset: 0;
64
+ background: #0017ff;
65
+ border: solid 1px #ffffff;
66
+ }
67
+ .cube-face.top {
68
+ transform: translateZ(80px);
69
+ }
70
+ .cube-face.front {
71
+ transform-origin: 0 50%;
72
+ transform: rotateY(-90deg);
73
+ }
74
+ .cube-face.back {
75
+ transform-origin: 0 50%;
76
+ transform: rotateY(-90deg) translateZ(-80px);
77
+ }
78
+ .cube-face.right {
79
+ transform-origin: 50% 0;
80
+ transform: rotateX(-90deg) translateY(-80px);
81
+ }
82
+ .cube-face.left {
83
+ transform-origin: 50% 0;
84
+ transform: rotateX(-90deg) translateY(-80px) translateZ(80px);
85
+ }
86
+
87
+ @keyframes rotation {
88
+ 0% {
89
+ transform: rotateX(45deg) rotateY(0) rotateZ(45deg);
90
+ animation-timing-function: cubic-bezier(
91
+ 0.17,
92
+ 0.84,
93
+ 0.44,
94
+ 1
95
+ );
96
+ }
97
+ 50% {
98
+ transform: rotateX(45deg) rotateY(0) rotateZ(225deg);
99
+ animation-timing-function: cubic-bezier(
100
+ 0.76,
101
+ 0.05,
102
+ 0.86,
103
+ 0.06
104
+ );
105
+ }
106
+ 100% {
107
+ transform: rotateX(45deg) rotateY(0) rotateZ(405deg);
108
+ animation-timing-function: cubic-bezier(
109
+ 0.17,
110
+ 0.84,
111
+ 0.44,
112
+ 1
113
+ );
114
+ }
115
+ }
116
+
117
+ .scene,
118
+ #message {
119
+ position: absolute;
120
+ display: flex;
121
+ top: 0;
122
+ right: 0;
123
+ left: 0;
124
+ bottom: 0;
125
+ z-index: 2;
126
+ height: 100%;
127
+ width: 100%;
128
+ align-items: center;
129
+ justify-content: center;
130
+ }
131
+ #message {
132
+ font-weight: bold;
133
+ font-size: large;
134
+ color: red;
135
+ pointer-events: none;
136
+ }
137
+
138
+ details {
139
+ font-size: small;
140
+
141
+ }
142
+
143
+ #progress {
144
+ position: absolute;
145
+ top: 0;
146
+ height: 5px;
147
+ background: blue;
148
+ z-index: 99;
149
+ transition: width 0.1s ease-in-out;
150
+ }
151
+
152
+ #quality {
153
+ position: absolute;
154
+ bottom: 10px;
155
+ z-index: 999;
156
+ right: 10px;
157
+ }
158
+
159
+ #caminfo {
160
+ position: absolute;
161
+ top: 10px;
162
+ z-index: 999;
163
+ right: 10px;
164
+ }
165
+ #canvas {
166
+ display: block;
167
+ position: absolute;
168
+ top: 0;
169
+ left: 0;
170
+ width: 100%;
171
+ height: 100%;
172
+ touch-action: none;
173
+ }
174
+
175
+ #instructions {
176
+ background: rgba(0,0,0,0.6);
177
+ white-space: pre-wrap;
178
+ padding: 10px;
179
+ border-radius: 10px;
180
+ font-size: x-small;
181
+ }
182
+ body.nohf .nohf {
183
+ display: none;
184
+ }
185
+ body.nohf #progress, body.nohf .cube-face {
186
+ background: #ff9d0d;
187
+ }
188
+ </style>
189
+ </head>
190
+ <body>
191
+ <script>
192
+ if(location.host.includes('hf.space')) document.body.classList.add('nohf');
193
+ </script>
194
+ <div id="info">
195
+ <h3 class="nohf">WebGL 3D Gaussian Splat Viewer</h3>
196
+ <p>
197
+ <small class="nohf">
198
+ By <a href="https://twitter.com/antimatter15">Kevin Kwok</a>.
199
+ Code on
200
+ <a href="https://github.com/antimatter15/splat">Github</a
201
+ >.
202
+ </small>
203
+ </p>
204
+
205
+ <details>
206
+ <summary>Use mouse or arrow keys to navigate.</summary>
207
+
208
+ <div id="instructions">movement (arrow keys)
209
+ - left/right arrow keys to strafe side to side
210
+ - up/down arrow keys to move forward/back
211
+ - space to jump
212
+
213
+ camera angle (wasd)
214
+ - a/d to turn camera left/right
215
+ - w/s to tilt camera up/down
216
+ - q/e to roll camera counterclockwise/clockwise
217
+ - i/k and j/l to orbit
218
+
219
+ trackpad
220
+ - scroll up/down/left/right to orbit
221
+ - pinch to move forward/back
222
+ - ctrl key + scroll to move forward/back
223
+ - shift + scroll to move up/down or strafe
224
+
225
+ mouse
226
+ - click and drag to orbit
227
+ - right click (or ctrl/cmd key) and drag up/down to move
228
+
229
+ touch (mobile)
230
+ - one finger to orbit
231
+ - two finger pinch to move forward/back
232
+ - two finger rotate to rotate camera clockwise/counterclockwise
233
+ - two finger pan to move side-to-side and up-down
234
+
235
+ gamepad
236
+ - if you have a game controller connected it should work
237
+
238
+ other
239
+ - press 0-9 to switch to one of the pre-loaded camera views
240
+ - press '-' or '+'key to cycle loaded cameras
241
+ - press p to resume default animation
242
+ - drag and drop .ply file to convert to .splat
243
+ - drag and drop cameras.json to load cameras
244
+ </div>
245
+
246
+ </details>
247
+
248
+ </div>
249
+
250
+ <div id="progress"></div>
251
+
252
+ <div id="message"></div>
253
+ <div class="scene" id="spinner">
254
+ <div class="cube-wrapper">
255
+ <div class="cube">
256
+ <div class="cube-faces">
257
+ <div class="cube-face bottom"></div>
258
+ <div class="cube-face top"></div>
259
+ <div class="cube-face left"></div>
260
+ <div class="cube-face right"></div>
261
+ <div class="cube-face back"></div>
262
+ <div class="cube-face front"></div>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ <canvas id="canvas"></canvas>
268
+
269
+ <div id="quality">
270
+ <span id="fps"></span>
271
+ </div>
272
+ <div id="caminfo">
273
+ <span id="camid"></span>
274
+ </div>
275
+ <script src="main.js"></script>
276
+ </body>
277
+ </html>