Add Emscripten WASM build and three.js web demo

web/ compiles the v3 C runtime with emcc (Emscripten SDK from
~/work/emsdk by default) and ships a simple browser demo that loads
glTF/GLB files via drag & drop and renders them with three.js:

- loader.c: C bridge exporting flattened primitives, materials,
  textures (bufferView + data-URI paths) and the scene graph to JS
- main.js/index.html/style.css: three.js viewer (OrbitControls,
  PBR materials, textures, node hierarchy, primitive modes)
- gen_sample.py: generates a small self-contained textured Cube.glb
  sample from tracked assets (no large binaries committed)
- Makefile: emcc build (MODULARIZE + ES6, no filesystem), sample
  generation and local http server targets

Verified end-to-end: native bridge tests, Node harness exercising the
WASM exports (parse, geometry, images, clear) and HTTP serving.
This commit is contained in:
Syoyo Fujita
2026-07-31 21:36:08 +09:00
parent 76a3b70751
commit a040827363
9 changed files with 1201 additions and 0 deletions

5
.gitignore vendored
View File

@@ -81,6 +81,11 @@ tests/tester_v3_json_c
tests/v3/fuzzer/fuzz_gltf_v3
tests/v3/fuzzer/fuzz_gltf_v3_c
# Generated by web/Makefile (make / make sample)
web/tinygltf_v3.js
web/tinygltf_v3.wasm
web/Cube.glb
# unignore
!Makefile
!tests/Makefile

View File

@@ -67,8 +67,17 @@ $ cmake -B build && cmake --build build && ctest --test-dir build --output-on-fa
# Meson
$ meson setup build && meson compile -C build && meson test -C build
# WebAssembly (requires an Emscripten SDK, defaults to ~/work/emsdk)
$ make -C web && make -C web sample
```
## Web/WASM demo
[`web/`](web/) contains a browser demo that compiles the v3 C runtime with
Emscripten and renders glTF/GLB files with three.js (file picker + drag &
drop). See [`web/README.md`](web/README.md) for build instructions.
## Legacy v1/v2 (C++)
The previous C++ implementation (`tiny_gltf.h`, `tiny_gltf.cc`,

44
web/Makefile Normal file
View File

@@ -0,0 +1,44 @@
# Build the tinygltf v3 C WASM module for the three.js demo.
#
# Requires an Emscripten SDK. The default EMSDK path is $(HOME)/work/emsdk;
# override with `make EMSDK=/path/to/emsdk`.
EMSDK ?= $(HOME)/work/emsdk
ROOT := $(abspath ../)
SHELL := /bin/bash
EMCC := source $(EMSDK)/emsdk_env.sh && emcc
CFLAGS := -O3 -std=c11 -Wall -Wextra -I$(ROOT)
EMFLAGS := -sMODULARIZE=1 \
-sEXPORT_ES6=1 \
-sEXPORT_NAME=createTinyGLTF \
-sENVIRONMENT=web \
-sALLOW_MEMORY_GROWTH=1 \
-sSTACK_SIZE=1048576 \
-sFILESYSTEM=0 \
-sEXPORTED_FUNCTIONS=_malloc,_free \
-sEXPORTED_RUNTIME_METHODS=getValue,setValue,UTF8ToString,HEAP32,HEAPU32,HEAPF32,HEAPU8
SRC := $(ROOT)/tiny_gltf_v3.c loader.c
OUT_JS := tinygltf_v3.js
OUT_WASM := tinygltf_v3.wasm
SAMPLE := Cube.glb
.PHONY: all sample serve clean
all: $(OUT_JS)
$(OUT_JS): loader.c Makefile $(ROOT)/tiny_gltf_v3.h $(ROOT)/tiny_gltf_v3.c $(ROOT)/tinygltf_json_c.h
$(EMCC) $(CFLAGS) $(EMFLAGS) -o $(OUT_JS) $(SRC)
# Generate a small self-contained textured sample GLB (Cube.glb).
sample:
python3 gen_sample.py
# Serve the demo locally (http://localhost:8000)
serve:
python3 -m http.server 8000
clean:
rm -f $(OUT_JS) $(OUT_WASM) $(SAMPLE)

59
web/README.md Normal file
View File

@@ -0,0 +1,59 @@
# tinygltf v3 C — Web/WASM demo (three.js)
A minimal single-page demo that parses glTF/GLB in the browser using the
tinygltf v3 C runtime compiled to WebAssembly, then renders the result with
[three.js](https://threejs.org).
```
web/
loader.c — C bridge: parse bytes, flatten primitives/materials/textures/nodes for JS
index.html — demo page (file picker + drag & drop)
main.js — three.js viewer consuming the WASM exports
style.css
Makefile — Emscripten build
gen_sample.py — generates Cube.glb (small self-contained textured cube)
Cube.glb — sample model, generated with `make sample`
```
## Requirements
* An Emscripten SDK. The Makefile defaults to `~/work/emsdk`
(override with `make EMSDK=/path/to/emsdk`).
## Build
```bash
$ cd web
$ make # produces tinygltf_v3.js + tinygltf_v3.wasm
$ make sample # generate Cube.glb (self-contained textured cube)
$ make serve # python3 -m http.server 8000
```
Then open http://localhost:8000 and drop a `.glb` / `.gltf` file onto the
page, or click **Load sample**.
## Notes
* Assets must be **self-contained**: GLB binary chunk or embedded data-URI
buffers/images. External `.bin` / image file references are not resolved
(no filesystem is linked into the module: `-sFILESYSTEM=0`).
* Primitives must use `float` `VEC3` positions (the common case); sparse
accessors and `double` attributes are skipped with a warning message.
* Image decoding happens client-side: raw image bytes are passed to
`createImageBitmap()` and wrapped in a `THREE.CanvasTexture`.
## C exports (loader.c)
All functions are exported as `_tg3w_*` on the Emscripten module:
| Function | Description |
| --- | --- |
| `tg3w_parse(ptr, size)` | Parse GLB/glTF bytes (auto-detect), flatten model |
| `tg3w_clear()` | Free the current model and flattened buffers |
| `tg3w_last_error()` / `tg3w_error_message(i)` / `tg3w_error_count()` | Parse diagnostics |
| `tg3w_prim(i)` | Primitive record (material, mode, counts, buffer offsets; `nrm_offset`/`uv_offset` are -1 when absent) |
| `tg3w_positions()/normals()/uvs()/indices()` | Flattened vertex/index arrays |
| `tg3w_mesh_prim_start/count(m)` | Primitive range of a mesh |
| `tg3w_material_*` | PBR factors, alpha mode, base color texture |
| `tg3w_texture_source(i)`, `tg3w_image_bytes/size/mime(i)` | Texture image bytes |
| `tg3w_node_*`, `tg3w_scene_*`, `tg3w_default_scene()` | Scene graph |

108
web/gen_sample.py Normal file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Generate a small self-contained textured cube GLB (web/Cube.glb).
Uses the tracked Cube assets from models/ (Cube.gltf geometry in Cube.bin)
plus a tiny checkerboard PNG generated with only the standard library, so
the demo ships without large binary assets.
"""
import base64
import json
import struct
import sys
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BIN = ROOT / "models" / "Cube" / "Cube.bin"
OUT = Path(__file__).resolve().parent / "Cube.glb"
def make_png(width=64, height=64, rgb=(230, 140, 60), rgb2=(245, 245, 245)):
"""Minimal zlib-based RGB PNG writer."""
def chunk(tag, data):
c = tag + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8-bit RGB
raw = bytearray()
for y in range(height):
raw.append(0) # filter: none
for x in range(width):
c = rgb if (x // 8 + y // 8) % 2 == 0 else rgb2
raw += bytes(c)
return (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", ihdr)
+ chunk(b"IDAT", zlib.compress(bytes(raw), 9))
+ chunk(b"IEND", b"")
)
def main():
bin_data = BIN.read_bytes()
png = make_png()
total = len(bin_data) + len(png)
j = {
"asset": {"version": "2.0", "generator": "tinygltf web demo (gen_sample.py)"},
"scene": 0,
"scenes": [{"nodes": [0]}],
"nodes": [{"mesh": 0, "name": "Cube"}],
"meshes": [{
"primitives": [{
"attributes": {
"POSITION": 1, "NORMAL": 2, "TANGENT": 3, "TEXCOORD_0": 4,
},
"indices": 0,
"material": 0,
}]
}],
"materials": [{
"name": "checker",
"pbrMetallicRoughness": {
"baseColorFactor": [1.0, 1.0, 1.0, 1.0],
"baseColorTexture": {"index": 0},
"metallicFactor": 0.0,
"roughnessFactor": 1.0,
},
}],
"textures": [{"source": 0, "sampler": 0}],
"images": [{"bufferView": 5, "mimeType": "image/png"}],
"samplers": [{"magFilter": 9729, "minFilter": 9987, "wrapS": 10497, "wrapT": 10497}],
"buffers": [{"byteLength": total}],
"bufferViews": [
{"buffer": 0, "byteOffset": 0, "byteLength": 72, "target": 34963}, # indices
{"buffer": 0, "byteOffset": 72, "byteLength": 432, "target": 34962}, # positions
{"buffer": 0, "byteOffset": 504, "byteLength": 432, "target": 34962}, # normals
{"buffer": 0, "byteOffset": 936, "byteLength": 576, "target": 34962}, # tangents
{"buffer": 0, "byteOffset": 1512, "byteLength": 288, "target": 34962}, # uvs
{"buffer": 0, "byteOffset": len(bin_data), "byteLength": len(png)}, # image
],
"accessors": [
{"bufferView": 0, "componentType": 5123, "count": 36, "type": "SCALAR", "min": [0], "max": [35]},
{"bufferView": 1, "componentType": 5126, "count": 36, "type": "VEC3", "min": [-1, -1, -1], "max": [1, 1, 1]},
{"bufferView": 2, "componentType": 5126, "count": 36, "type": "VEC3"},
{"bufferView": 3, "componentType": 5126, "count": 36, "type": "VEC4"},
{"bufferView": 4, "componentType": 5126, "count": 36, "type": "VEC2"},
],
}
json_chunk = json.dumps(j, separators=(",", ":")).encode("utf-8")
json_chunk += b" " * ((4 - len(json_chunk) % 4) % 4)
bin_chunk = bin_data + png
bin_chunk += b"\x00" * ((4 - len(bin_chunk) % 4) % 4)
total_len = 12 + 8 + len(json_chunk) + 8 + len(bin_chunk)
glb = struct.pack("<4sII", b"glTF", 2, total_len)
glb += struct.pack("<II", len(json_chunk), 0x4E4F534A) + json_chunk
glb += struct.pack("<II", len(bin_chunk), 0x004E4942) + bin_chunk
OUT.write_bytes(glb)
print(f"wrote {OUT} ({len(glb)} bytes, image {len(png)} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main())

28
web/index.html Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>tinygltf v3 — three.js WASM demo</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>tinygltf v3 (C) + three.js</h1>
<div class="controls">
<label class="btn" for="file-input">Open glTF/GLB…</label>
<input type="file" id="file-input" accept=".gltf,.glb,model/gltf+json,model/gltf-binary" hidden>
<button class="btn" id="sample-btn" hidden>Load sample (Cube.glb)</button>
</div>
</header>
<main>
<div id="drop-zone">
<p>Drop a <code>.glb</code> / <code>.gltf</code> file here, or use <b>Open glTF/GLB…</b></p>
<p class="hint">Note: assets must be self-contained (GLB chunk or embedded data URI).</p>
</div>
<div id="viewer" hidden></div>
</main>
<footer id="status">Loading WASM module…</footer>
<script type="module" src="main.js"></script>
</body>
</html>

548
web/loader.c Normal file
View File

@@ -0,0 +1,548 @@
/*
* loader.c — tinygltf v3 C WASM bridge for the three.js demo.
*
* Parses glTF/GLB bytes passed in from JavaScript (Emscripten HEAPU8),
* then exposes flattened per-primitive vertex/index data plus materials,
* textures and the node hierarchy through EMSCRIPTEN_KEEPALIVE functions.
*
* Build with emcc (see Makefile). No filesystem or image decoding is
* used: all assets must be embedded in the file (GLB chunk or data URI).
*/
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "tiny_gltf_v3.h"
#if defined(__EMSCRIPTEN__)
#include <emscripten.h>
#define TG3W_EXPORT EMSCRIPTEN_KEEPALIVE
#else
#define TG3W_EXPORT
#endif
/* ------------------------------------------------------------------ */
/* Exported model info */
/* ------------------------------------------------------------------ */
typedef struct {
int32_t material; /* material index or -1 */
int32_t mode; /* TG3_MODE_* */
uint32_t vertex_count;
uint32_t index_count;
uint32_t pos_offset; /* offset in tg3w_positions (floats, 3 * vertex_count) */
int32_t nrm_offset; /* -1 = absent; floats, 3 * vertex_count */
int32_t uv_offset; /* -1 = absent; floats, 2 * vertex_count */
uint32_t idx_offset; /* offset in tg3w_indices (uint32) */
} tg3w_prim;
typedef struct {
float base_color[4];
float metallic;
float roughness;
int32_t base_color_texture; /* texture index or -1 */
int32_t alpha_mode; /* 0 = OPAQUE, 1 = MASK, 2 = BLEND */
float alpha_cutoff;
int32_t double_sided;
} tg3w_mat;
typedef struct {
int32_t mesh; /* mesh index or -1 */
float translation[3];
float rotation[4];
float scale[3];
} tg3w_node;
/* ------------------------------------------------------------------ */
/* Module state (single parse at a time, like a simple loading screen) */
/* ------------------------------------------------------------------ */
static tg3_model g_model;
static tg3_error_stack g_errors;
static tg3_parse_options g_opts_;
static tg3w_prim *g_prims = NULL;
static uint32_t g_prim_count = 0;
static uint32_t *g_mesh_prim_start = NULL;
static uint32_t *g_mesh_prim_count = NULL;
static float *g_positions = NULL;
static float *g_normals = NULL;
static float *g_uvs = NULL;
static uint32_t *g_indices = NULL;
static char g_last_error[512];
static int g_inited = 0;
/* Free the current model and all flattened buffers. */
TG3W_EXPORT void tg3w_clear(void) {
tg3_model_free(&g_model);
free(g_prims); g_prims = NULL;
free(g_positions); g_positions = NULL;
free(g_normals); g_normals = NULL;
free(g_uvs); g_uvs = NULL;
free(g_indices); g_indices = NULL;
free(g_mesh_prim_start); g_mesh_prim_start = NULL;
free(g_mesh_prim_count); g_mesh_prim_count = NULL;
g_prim_count = 0;
}
static void tg3w_init_once(void) {
if (!g_inited) {
tg3_parse_options_init(&g_opts_);
tg3_error_stack_init(&g_errors);
g_inited = 1;
}
}
/* ------------------------------------------------------------------ */
/* Accessor helpers */
/* ------------------------------------------------------------------ */
static uint32_t tg3w_type_components(int32_t type) {
switch (type) {
case TG3_TYPE_SCALAR: return 1;
case TG3_TYPE_VEC2: return 2;
case TG3_TYPE_VEC3: return 3;
case TG3_TYPE_VEC4: return 4;
case TG3_TYPE_MAT4: return 16;
default: return 0;
}
}
static const uint8_t *tg3w_accessor_ptr(const tg3_model *m,
const tg3_accessor *a,
uint64_t *stride_out) {
if (a->buffer_view < 0 || a->sparse.is_sparse) {
return NULL;
}
const tg3_buffer_view *bv = &m->buffer_views[a->buffer_view];
if (bv->buffer < 0) {
return NULL;
}
const tg3_buffer *b = &m->buffers[bv->buffer];
if (!b->data.data || b->data.count < bv->byte_offset + bv->byte_length) {
return NULL;
}
uint32_t comps = tg3w_type_components(a->type);
uint64_t elem = 0;
switch (a->component_type) {
case TG3_COMPONENT_TYPE_FLOAT: elem = 4; break;
case TG3_COMPONENT_TYPE_DOUBLE: elem = 8; break;
case TG3_COMPONENT_TYPE_UNSIGNED_BYTE: elem = 1; break;
case TG3_COMPONENT_TYPE_BYTE: elem = 1; break;
case TG3_COMPONENT_TYPE_UNSIGNED_SHORT: elem = 2; break;
case TG3_COMPONENT_TYPE_SHORT: elem = 2; break;
case TG3_COMPONENT_TYPE_UNSIGNED_INT: elem = 4; break;
case TG3_COMPONENT_TYPE_INT: elem = 4; break;
default: return NULL;
}
uint64_t stride = bv->byte_stride ? bv->byte_stride : elem * comps;
*stride_out = stride;
return b->data.data + bv->byte_offset + a->byte_offset;
}
static int tg3w_attr_index(const tg3_primitive *p, const char *name) {
for (uint32_t i = 0; i < p->attributes_count; i++) {
const tg3_str_int_pair *a = &p->attributes[i];
if (a->key.len == (uint32_t)strlen(name) &&
strncmp(a->key.data, name, a->key.len) == 0) {
return a->value;
}
}
return -1;
}
/* ------------------------------------------------------------------ */
/* Parse + flatten */
/* ------------------------------------------------------------------ */
TG3W_EXPORT int tg3w_parse(const uint8_t *data, uint32_t size) {
tg3w_init_once();
tg3_model_free(&g_model);
tg3_error_stack_free(&g_errors);
tg3_error_stack_init(&g_errors);
tg3_parse_options_init(&g_opts_);
tg3_error_code err = tg3_parse_auto(&g_model, &g_errors, data, size,
NULL, 0, &g_opts_);
if (err != TG3_OK) {
snprintf(g_last_error, sizeof(g_last_error), "parse failed: %d", (int)err);
return (int)err;
}
/* Pass 1: count vertices/indices per primitive. */
uint32_t prims = 0;
uint64_t total_pos = 0, total_nrm = 0, total_uv = 0, total_idx = 0;
for (uint32_t mi = 0; mi < g_model.meshes_count; mi++) {
const tg3_mesh *mesh = &g_model.meshes[mi];
for (uint32_t pi = 0; pi < mesh->primitives_count; pi++) {
const tg3_primitive *p = &mesh->primitives[pi];
int pos_i = tg3w_attr_index(p, "POSITION");
int nrm_i = tg3w_attr_index(p, "NORMAL");
int uv_i = tg3w_attr_index(p, "TEXCOORD_0");
uint64_t vcount = 0;
if (pos_i >= 0) {
const tg3_accessor *a = &g_model.accessors[pos_i];
if (a->component_type == TG3_COMPONENT_TYPE_FLOAT &&
a->type == TG3_TYPE_VEC3 && !a->sparse.is_sparse) {
vcount = a->count;
}
}
uint64_t icount = 0;
if (p->indices >= 0) {
const tg3_accessor *a = &g_model.accessors[p->indices];
if (!a->sparse.is_sparse) {
switch (a->component_type) {
case TG3_COMPONENT_TYPE_UNSIGNED_BYTE:
case TG3_COMPONENT_TYPE_UNSIGNED_SHORT:
case TG3_COMPONENT_TYPE_UNSIGNED_INT:
icount = a->count;
break;
default: break;
}
}
}
if (vcount == 0) {
continue; /* unsupported primitive (e.g. sparse/double) */
}
total_pos += vcount * 3;
if (nrm_i >= 0) {
const tg3_accessor *a = &g_model.accessors[nrm_i];
if (a->component_type == TG3_COMPONENT_TYPE_FLOAT &&
a->type == TG3_TYPE_VEC3 && !a->sparse.is_sparse) {
total_nrm += vcount * 3;
}
}
if (uv_i >= 0) {
const tg3_accessor *a = &g_model.accessors[uv_i];
if (a->component_type == TG3_COMPONENT_TYPE_FLOAT &&
a->type == TG3_TYPE_VEC2 && !a->sparse.is_sparse) {
total_uv += vcount * 2;
}
}
total_idx += icount;
prims++;
}
}
if (prims == 0) {
snprintf(g_last_error, sizeof(g_last_error),
"no renderable primitives (float VEC3 POSITION required)");
return -1;
}
/* Allocate flattened arrays. */
free(g_prims); free(g_positions); free(g_normals); free(g_uvs); free(g_indices);
free(g_mesh_prim_start); free(g_mesh_prim_count);
g_prims = (tg3w_prim *)malloc(prims * sizeof(tg3w_prim));
g_positions = (float *)malloc(total_pos * sizeof(float));
g_normals = total_nrm ? (float *)malloc(total_nrm * sizeof(float)) : NULL;
g_uvs = total_uv ? (float *)malloc(total_uv * sizeof(float)) : NULL;
g_indices = (uint32_t *)malloc(total_idx * sizeof(uint32_t));
g_mesh_prim_start = (uint32_t *)malloc(g_model.meshes_count * sizeof(uint32_t));
g_mesh_prim_count = (uint32_t *)malloc(g_model.meshes_count * sizeof(uint32_t));
g_prim_count = 0;
if (!g_prims || (total_pos && !g_positions) || (total_nrm && !g_normals) ||
(total_uv && !g_uvs) || (total_idx && !g_indices) ||
!g_mesh_prim_start || !g_mesh_prim_count) {
snprintf(g_last_error, sizeof(g_last_error), "out of memory");
tg3w_clear();
return -1;
}
memset(g_mesh_prim_start, 0, g_model.meshes_count * sizeof(uint32_t));
memset(g_mesh_prim_count, 0, g_model.meshes_count * sizeof(uint32_t));
/* Pass 2: fill. */
uint32_t p_off = 0, n_off = 0, u_off = 0, i_off = 0;
for (uint32_t mi = 0; mi < g_model.meshes_count; mi++) {
const tg3_mesh *mesh = &g_model.meshes[mi];
g_mesh_prim_start[mi] = g_prim_count;
for (uint32_t pi = 0; pi < mesh->primitives_count; pi++) {
const tg3_primitive *p = &mesh->primitives[pi];
int pos_i = tg3w_attr_index(p, "POSITION");
uint64_t vcount = 0;
uint64_t pstride = 0;
const uint8_t *pp = NULL;
if (pos_i >= 0) {
const tg3_accessor *a = &g_model.accessors[pos_i];
if (a->component_type == TG3_COMPONENT_TYPE_FLOAT &&
a->type == TG3_TYPE_VEC3 && !a->sparse.is_sparse) {
vcount = a->count;
pp = tg3w_accessor_ptr(&g_model, a, &pstride);
}
}
if (!pp || vcount == 0) {
continue;
}
tg3w_prim *out = &g_prims[g_prim_count];
memset(out, 0, sizeof(*out));
out->nrm_offset = -1;
out->uv_offset = -1;
out->material = p->material;
out->mode = (p->mode == -1) ? TG3_MODE_TRIANGLES : p->mode;
out->vertex_count = (uint32_t)vcount;
out->pos_offset = p_off;
for (uint64_t v = 0; v < vcount; v++) {
const float *f = (const float *)(pp + v * pstride);
g_positions[p_off + (uint32_t)v * 3 + 0] = f[0];
g_positions[p_off + (uint32_t)v * 3 + 1] = f[1];
g_positions[p_off + (uint32_t)v * 3 + 2] = f[2];
}
p_off += (uint32_t)vcount * 3;
int nrm_i = tg3w_attr_index(p, "NORMAL");
if (nrm_i >= 0) {
const tg3_accessor *a = &g_model.accessors[nrm_i];
uint64_t stride = 0;
const uint8_t *np = tg3w_accessor_ptr(&g_model, a, &stride);
if (np && a->component_type == TG3_COMPONENT_TYPE_FLOAT &&
a->type == TG3_TYPE_VEC3 && a->count == vcount) {
out->nrm_offset = (int32_t)n_off;
for (uint64_t v = 0; v < vcount; v++) {
const float *f = (const float *)(np + v * stride);
g_normals[n_off + (uint32_t)v * 3 + 0] = f[0];
g_normals[n_off + (uint32_t)v * 3 + 1] = f[1];
g_normals[n_off + (uint32_t)v * 3 + 2] = f[2];
}
n_off += (uint32_t)vcount * 3;
}
}
int uv_i = tg3w_attr_index(p, "TEXCOORD_0");
if (uv_i >= 0) {
const tg3_accessor *a = &g_model.accessors[uv_i];
uint64_t stride = 0;
const uint8_t *up = tg3w_accessor_ptr(&g_model, a, &stride);
if (up && a->component_type == TG3_COMPONENT_TYPE_FLOAT &&
a->type == TG3_TYPE_VEC2 && a->count == vcount) {
out->uv_offset = (int32_t)u_off;
for (uint64_t v = 0; v < vcount; v++) {
const float *f = (const float *)(up + v * stride);
g_uvs[u_off + (uint32_t)v * 2 + 0] = f[0];
g_uvs[u_off + (uint32_t)v * 2 + 1] = f[1];
}
u_off += (uint32_t)vcount * 2;
}
}
if (p->indices >= 0) {
const tg3_accessor *a = &g_model.accessors[p->indices];
uint64_t stride = 0;
const uint8_t *ip = tg3w_accessor_ptr(&g_model, a, &stride);
if (ip && !a->sparse.is_sparse) {
out->index_count = (uint32_t)a->count;
out->idx_offset = i_off;
for (uint64_t v = 0; v < a->count; v++) {
uint32_t idx = 0;
switch (a->component_type) {
case TG3_COMPONENT_TYPE_UNSIGNED_BYTE:
idx = ((const uint8_t *)ip)[v * stride];
break;
case TG3_COMPONENT_TYPE_UNSIGNED_SHORT:
idx = ((const uint16_t *)ip)[v * stride / 2];
break;
case TG3_COMPONENT_TYPE_UNSIGNED_INT:
idx = ((const uint32_t *)ip)[v * stride / 4];
break;
default: break;
}
g_indices[i_off + (uint32_t)v] = idx;
}
i_off += (uint32_t)a->count;
}
}
g_prim_count++;
}
g_mesh_prim_count[mi] = g_prim_count - g_mesh_prim_start[mi];
}
g_last_error[0] = '\0';
return 0;
}
/* ------------------------------------------------------------------ */
/* Getters */
/* ------------------------------------------------------------------ */
TG3W_EXPORT const char *tg3w_last_error(void) { return g_last_error; }
TG3W_EXPORT uint32_t tg3w_error_count(void) { return g_errors.count; }
TG3W_EXPORT const char *tg3w_error_message(uint32_t i) {
if (i >= g_errors.count || !g_errors.entries[i].message) return "";
return g_errors.entries[i].message;
}
TG3W_EXPORT int tg3w_error_severity(uint32_t i) {
if (i >= g_errors.count) return -1;
return (int)g_errors.entries[i].severity;
}
TG3W_EXPORT uint32_t tg3w_prim_count(void) { return g_prim_count; }
TG3W_EXPORT const tg3w_prim *tg3w_prim_at(uint32_t i) {
if (i >= g_prim_count) return NULL;
return &g_prims[i];
}
TG3W_EXPORT const float *tg3w_positions(void) { return g_positions; }
TG3W_EXPORT const float *tg3w_normals(void) { return g_normals; }
TG3W_EXPORT const float *tg3w_uvs(void) { return g_uvs; }
TG3W_EXPORT const uint32_t *tg3w_indices(void) { return g_indices; }
TG3W_EXPORT uint32_t tg3w_mesh_count(void) { return g_model.meshes_count; }
TG3W_EXPORT uint32_t tg3w_mesh_prim_start(uint32_t m) {
if (m >= g_model.meshes_count) return 0;
return g_mesh_prim_start[m];
}
TG3W_EXPORT uint32_t tg3w_mesh_prim_count(uint32_t m) {
if (m >= g_model.meshes_count) return 0;
return g_mesh_prim_count[m];
}
/* ------------------------------------------------------------------ */
/* Materials */
/* ------------------------------------------------------------------ */
TG3W_EXPORT uint32_t tg3w_material_count(void) { return g_model.materials_count; }
TG3W_EXPORT const float *tg3w_material_base_color(uint32_t i) {
static float c[4];
if (i >= g_model.materials_count) { c[0]=1;c[1]=1;c[2]=1;c[3]=1; return c; }
const tg3_material *m = &g_model.materials[i];
for (int k = 0; k < 4; k++) c[k] = (float)m->pbr_metallic_roughness.base_color_factor[k];
return c;
}
TG3W_EXPORT float tg3w_material_metallic(uint32_t i) {
if (i >= g_model.materials_count) return 1.0f;
return (float)g_model.materials[i].pbr_metallic_roughness.metallic_factor;
}
TG3W_EXPORT float tg3w_material_roughness(uint32_t i) {
if (i >= g_model.materials_count) return 1.0f;
return (float)g_model.materials[i].pbr_metallic_roughness.roughness_factor;
}
TG3W_EXPORT int tg3w_material_base_color_texture(uint32_t i) {
if (i >= g_model.materials_count) return -1;
return g_model.materials[i].pbr_metallic_roughness.base_color_texture.index;
}
TG3W_EXPORT int tg3w_material_alpha_mode(uint32_t i) {
if (i >= g_model.materials_count) return 0;
const tg3_str *a = &g_model.materials[i].alpha_mode;
if (a->len == 5 && strncmp(a->data, "BLEND", 5) == 0) return 2;
if (a->len == 4 && strncmp(a->data, "MASK", 4) == 0) return 1;
return 0;
}
TG3W_EXPORT float tg3w_material_alpha_cutoff(uint32_t i) {
if (i >= g_model.materials_count) return 0.5f;
return (float)g_model.materials[i].alpha_cutoff;
}
TG3W_EXPORT int tg3w_material_double_sided(uint32_t i) {
if (i >= g_model.materials_count) return 0;
return g_model.materials[i].double_sided;
}
/* ------------------------------------------------------------------ */
/* Textures (raw image bytes, decoded client-side) */
/* ------------------------------------------------------------------ */
TG3W_EXPORT uint32_t tg3w_texture_count(void) { return g_model.textures_count; }
TG3W_EXPORT uint32_t tg3w_image_count(void) { return g_model.images_count; }
TG3W_EXPORT int tg3w_texture_source(uint32_t i) {
if (i >= g_model.textures_count) return -1;
return g_model.textures[i].source;
}
/* Image payload: either a bufferView (GLB / .bin) or a data URI in the
* glTF JSON. The v3 parser does not decode image bytes itself, so expose
* both paths to JS. */
TG3W_EXPORT int tg3w_image_buffer_view(uint32_t i) {
if (i >= g_model.images_count) return -1;
return g_model.images[i].buffer_view;
}
TG3W_EXPORT const char *tg3w_image_uri(uint32_t i) {
if (i >= g_model.images_count) return NULL;
return g_model.images[i].uri.data ? g_model.images[i].uri.data : NULL;
}
TG3W_EXPORT const uint8_t *tg3w_image_bytes(uint32_t i) {
if (i >= g_model.images_count) return NULL;
int32_t bv = g_model.images[i].buffer_view;
if (bv < 0 || bv >= (int32_t)g_model.buffer_views_count) return NULL;
const tg3_buffer_view *v = &g_model.buffer_views[bv];
if (v->buffer < 0 || v->buffer >= (int32_t)g_model.buffers_count) return NULL;
const tg3_buffer *b = &g_model.buffers[v->buffer];
if (!b->data.data || b->data.count < v->byte_offset + v->byte_length) return NULL;
return b->data.data + v->byte_offset;
}
TG3W_EXPORT uint64_t tg3w_image_size(uint32_t i) {
if (i >= g_model.images_count) return 0;
int32_t bv = g_model.images[i].buffer_view;
if (bv < 0 || bv >= (int32_t)g_model.buffer_views_count) return 0;
return g_model.buffer_views[bv].byte_length;
}
TG3W_EXPORT const char *tg3w_image_mime(uint32_t i) {
if (i >= g_model.images_count) return "";
return g_model.images[i].mime_type.data ? g_model.images[i].mime_type.data : "";
}
/* ------------------------------------------------------------------ */
/* Scene graph (default scene only) */
/* ------------------------------------------------------------------ */
TG3W_EXPORT uint32_t tg3w_node_count(void) { return g_model.nodes_count; }
TG3W_EXPORT int32_t tg3w_node_mesh(uint32_t i) {
if (i >= g_model.nodes_count) return -1;
return g_model.nodes[i].mesh;
}
TG3W_EXPORT uint32_t tg3w_node_child_count(uint32_t i) {
if (i >= g_model.nodes_count) return 0;
return g_model.nodes[i].children_count;
}
TG3W_EXPORT const int32_t *tg3w_node_children(uint32_t i) {
if (i >= g_model.nodes_count) return NULL;
return g_model.nodes[i].children;
}
TG3W_EXPORT const float *tg3w_node_trs(uint32_t i) {
static float trs[10];
if (i >= g_model.nodes_count) { memset(trs, 0, sizeof(trs)); trs[6] = 1.0f; return trs; }
const tg3_node *n = &g_model.nodes[i];
for (int k = 0; k < 3; k++) trs[k] = (float)n->translation[k];
for (int k = 0; k < 4; k++) trs[3 + k] = (float)n->rotation[k];
for (int k = 0; k < 3; k++) trs[7 + k] = (float)n->scale[k];
return trs;
}
TG3W_EXPORT int tg3w_default_scene(void) { return g_model.default_scene; }
TG3W_EXPORT uint32_t tg3w_scene_count(void) { return g_model.scenes_count; }
TG3W_EXPORT uint32_t tg3w_scene_node_count(uint32_t i) {
if (i >= g_model.scenes_count) return 0;
return g_model.scenes[i].nodes_count;
}
TG3W_EXPORT const int32_t *tg3w_scene_nodes(uint32_t i) {
if (i >= g_model.scenes_count) return NULL;
return g_model.scenes[i].nodes;
}

309
web/main.js Normal file
View File

@@ -0,0 +1,309 @@
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js';
import createTinyGLTF from './tinygltf_v3.js';
const statusEl = document.getElementById('status');
const dropZone = document.getElementById('drop-zone');
const viewerEl = document.getElementById('viewer');
const fileInput = document.getElementById('file-input');
const sampleBtn = document.getElementById('sample-btn');
function setStatus(msg) {
statusEl.textContent = msg;
statusEl.title = msg;
}
let Module = null;
let renderer = null;
let scene = null;
let camera = null;
let controls = null;
/* ---------------------------------------------------------------- */
/* Scene construction */
/* ---------------------------------------------------------------- */
// tg3w_prim layout in bytes: material, mode, vertex_count, index_count,
// pos_offset, nrm_offset, uv_offset, idx_offset (all 4-byte ints).
function readPrim(i) {
const p = Module._tg3w_prim(i);
const get = (off) => Module.getValue(p + off, 'i32');
return {
material: get(0),
mode: get(4),
vertexCount: get(8),
indexCount: get(12),
posOffset: get(16),
nrmOffset: get(20),
uvOffset: get(24),
idxOffset: get(28),
};
}
function copyF32(offsetFloats, count) {
if (!offsetFloats || !count) return null;
return Module.HEAPF32.subarray(offsetFloats, offsetFloats + count).slice();
}
function makeTexture(texIdx) {
const src = Module._tg3w_texture_source(texIdx);
if (src < 0) return null;
let blobPromise = null;
const bv = Module._tg3w_image_buffer_view(src);
if (bv >= 0) {
const ptr = Module._tg3w_image_bytes(src);
const size = Module._tg3w_image_size(src);
if (!ptr || !size) return null;
const bytes = Module.HEAPU8.slice(ptr, ptr + size);
const mime = Module.UTF8ToString(Module._tg3w_image_mime(src)) || 'image/png';
blobPromise = Promise.resolve(new Blob([bytes], { type: mime }));
} else {
const uriPtr = Module._tg3w_image_uri(src);
if (!uriPtr) return null;
const uri = Module.UTF8ToString(uriPtr);
const m = /^data:([^;,]+)(;base64)?,(.*)$/s.exec(uri);
if (!m) return null; // external file URI — not resolvable without FS
const bytes = m[2]
? Uint8Array.from(atob(m[3]), (c) => c.charCodeAt(0))
: new TextEncoder().encode(decodeURIComponent(m[3]));
blobPromise = Promise.resolve(new Blob([bytes], { type: m[1] }));
}
return blobPromise.then((blob) => createImageBitmap(blob)).then((bmp) => {
const tex = new THREE.CanvasTexture(bmp);
tex.colorSpace = THREE.SRGBColorSpace;
return tex;
});
}
function makeMaterial(mi) {
const color = copyF32(Module._tg3w_material_base_color(mi) / 4, 4);
const metalness = Module._tg3w_material_metallic(mi);
const roughness = Module._tg3w_material_roughness(mi);
const alphaMode = Module._tg3w_material_alpha_mode(mi);
const alphaCutoff = Module._tg3w_material_alpha_cutoff(mi);
const doubleSided = Module._tg3w_material_double_sided(mi);
const mat = new THREE.MeshStandardMaterial({
color: new THREE.Color(color[0], color[1], color[2]),
metalness,
roughness,
transparent: alphaMode === 2,
alphaTest: alphaMode === 1 ? alphaCutoff : 0,
side: doubleSided ? THREE.DoubleSide : THREE.FrontSide,
});
const texIdx = Module._tg3w_material_base_color_texture(mi);
if (texIdx >= 0) {
return makeTexture(texIdx).then((tex) => {
if (tex) mat.map = tex;
return mat;
});
}
return Promise.resolve(mat);
}
// Convert triangle strip/fan index buffers to plain triangles.
function triangulate(indices, mode) {
if (mode === 4) return indices;
const out = [];
const n = indices.length;
if (mode === 5) { // TRIANGLE_STRIP
for (let i = 2; i < n; i++) {
if (i % 2 === 0) out.push(indices[i - 2], indices[i - 1], indices[i]);
else out.push(indices[i - 1], indices[i - 2], indices[i]);
}
} else if (mode === 6) { // TRIANGLE_FAN
for (let i = 2; i < n; i++) out.push(indices[0], indices[i - 1], indices[i]);
}
return out;
}
function makePrimitive(i) {
const p = readPrim(i);
const positions = copyF32(p.posOffset, p.vertexCount * 3);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
if (p.nrmOffset >= 0) {
geometry.setAttribute('normal', new THREE.BufferAttribute(copyF32(p.nrmOffset, p.vertexCount * 3), 3));
}
if (p.uvOffset >= 0) {
geometry.setAttribute('uv', new THREE.BufferAttribute(copyF32(p.uvOffset, p.vertexCount * 2), 2));
}
if (p.indexCount) {
const raw = Module.HEAPU32.subarray(p.idxOffset, p.idxOffset + p.indexCount).slice();
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(triangulate(raw, p.mode)), 1));
}
return makeMaterial(p.material).then((mat) => {
if (p.mode === 0) return new THREE.Points(geometry, mat);
if (p.mode === 1 || p.mode === 2 || p.mode === 3) {
const cls = p.mode === 1 ? THREE.Line : p.mode === 2 ? THREE.LineLoop : THREE.LineSegments;
return new cls(geometry, new THREE.LineBasicMaterial({ color: mat.color }));
}
return new THREE.Mesh(geometry, mat);
});
}
function buildNodeGraph(nodeIndices, parent) {
const jobs = [];
const visit = (nodeIdx, parentGroup) => {
const trs = copyF32(Module._tg3w_node_trs(nodeIdx) / 4, 10);
const group = new THREE.Group();
group.position.set(trs[0], trs[1], trs[2]);
group.quaternion.set(trs[3], trs[4], trs[5], trs[6]);
group.scale.set(trs[7], trs[8], trs[9]);
const meshIdx = Module._tg3w_node_mesh(nodeIdx);
if (meshIdx >= 0) {
const start = Module._tg3w_mesh_prim_start(meshIdx);
const count = Module._tg3w_mesh_prim_count(meshIdx);
for (let k = 0; k < count; k++) {
jobs.push(makePrimitive(start + k).then((mesh) => {
mesh.name = `mesh${meshIdx}[${k}]`;
group.add(mesh);
}));
}
}
const childCount = Module._tg3w_node_child_count(nodeIdx);
const childrenPtr = Module._tg3w_node_children(nodeIdx);
for (let c = 0; c < childCount; c++) {
const childIdx = Module.getValue(childrenPtr + c * 4, 'i32');
visit(childIdx, group);
}
parentGroup.add(group);
};
for (const n of nodeIndices) visit(n, parent);
return Promise.all(jobs).then(() => parent);
}
async function buildScene() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x14161a);
scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 1.0));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(5, 8, 6);
scene.add(dirLight);
const root = new THREE.Group();
scene.add(root);
const sceneCount = Module._tg3w_scene_count();
let nodes = [];
const defaultScene = Module._tg3w_default_scene();
if (defaultScene >= 0 && defaultScene < sceneCount) {
const n = Module._tg3w_scene_node_count(defaultScene);
const p = Module._tg3w_scene_nodes(defaultScene);
for (let i = 0; i < n; i++) nodes.push(Module.getValue(p + i * 4, 'i32'));
} else {
for (let i = 0; i < Module._tg3w_node_count(); i++) nodes.push(i);
}
await buildNodeGraph(nodes, root);
// Frame the camera on the geometry.
const box = new THREE.Box3().setFromObject(root);
if (!box.isEmpty()) {
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3()).length() || 1;
camera.position.copy(center).add(new THREE.Vector3(size, size * 0.8, size));
camera.lookAt(center);
controls.target.copy(center);
controls.update();
}
viewerEl.hidden = false;
dropZone.hidden = true;
setStatus(`Rendered: ${Module._tg3w_prim_count()} primitives, ${Module._tg3w_node_count()} nodes`);
}
/* ---------------------------------------------------------------- */
/* Loading */
/* ---------------------------------------------------------------- */
async function loadModel(bytes) {
setStatus('Parsing with tinygltf v3 (C/WASM)…');
const ptr = Module._malloc(bytes.length);
Module.HEAPU8.set(bytes, ptr);
const rc = Module._tg3w_parse(ptr, bytes.length);
Module._free(ptr);
if (rc !== 0) {
const first = Module.UTF8ToString(Module._tg3w_error_message(0));
const last = Module.UTF8ToString(Module._tg3w_last_error());
setStatus(`Parse failed (rc=${rc}): ${first || last}`);
return;
}
const warn = Module._tg3w_error_count();
await buildScene();
if (warn > 0) {
setStatus(`Loaded with ${warn} warning(s) — see console`);
for (let i = 0; i < warn; i++) {
console.warn(`[tg3] ${Module.UTF8ToString(Module._tg3w_error_message(i))}`);
}
}
}
async function handleFile(file) {
if (!file) return;
const buf = await file.arrayBuffer();
await loadModel(new Uint8Array(buf));
}
/* ---------------------------------------------------------------- */
/* Setup */
/* ---------------------------------------------------------------- */
async function init() {
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(viewerEl.clientWidth, viewerEl.clientHeight);
viewerEl.appendChild(renderer.domElement);
camera = new THREE.PerspectiveCamera(50, viewerEl.clientWidth / viewerEl.clientHeight, 0.01, 10000);
controls = new OrbitControls(camera, renderer.domElement);
window.addEventListener('resize', () => {
const w = viewerEl.clientWidth;
const h = viewerEl.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
});
renderer.setAnimationLoop(() => {
if (scene) renderer.render(scene, camera);
});
try {
Module = await createTinyGLTF();
} catch (e) {
setStatus(`Failed to load WASM: ${e}`);
return;
}
setStatus('WASM ready — drop a .glb/.gltf file');
fileInput.addEventListener('change', () => handleFile(fileInput.files[0]));
sampleBtn.addEventListener('click', async () => {
try {
const res = await fetch('Cube.glb');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await loadModel(new Uint8Array(await res.arrayBuffer()));
} catch (e) {
setStatus(`Sample load failed: ${e}`);
}
});
sampleBtn.hidden = false;
for (const ev of ['dragenter', 'dragover']) {
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add('dragover'); });
}
for (const ev of ['dragleave', 'drop']) {
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); });
}
dropZone.addEventListener('drop', (e) => {
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (f) handleFile(f);
});
}
init();

91
web/style.css Normal file
View File

@@ -0,0 +1,91 @@
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
font-family: system-ui, sans-serif;
background: #14161a;
color: #e8e8e8;
}
body {
display: flex;
flex-direction: column;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #1c1f26;
border-bottom: 1px solid #2c313a;
}
header h1 {
font-size: 16px;
margin: 0;
font-weight: 600;
}
.controls { display: flex; gap: 8px; }
.btn {
padding: 6px 14px;
font-size: 13px;
color: #e8e8e8;
background: #2d66d6;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn:hover { background: #3b76e8; }
main {
flex: 1;
display: flex;
position: relative;
min-height: 0;
}
#drop-zone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
border: 2px dashed #3a4150;
margin: 16px;
border-radius: 8px;
text-align: center;
transition: border-color 0.15s ease, background 0.15s ease;
}
#drop-zone.dragover {
border-color: #2d66d6;
background: #1a2030;
}
#drop-zone p { margin: 0; font-size: 15px; }
#drop-zone .hint { font-size: 12px; color: #8a93a5; }
#drop-zone code { background: #232833; padding: 2px 6px; border-radius: 3px; }
#viewer {
flex: 1;
min-height: 0;
}
#viewer canvas { display: block; width: 100%; height: 100%; }
footer {
padding: 6px 16px;
font-size: 12px;
color: #9aa3b4;
background: #1c1f26;
border-top: 1px solid #2c313a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}