From a04082736354cdca88556a4242ddb7de6df9ee23 Mon Sep 17 00:00:00 2001 From: Syoyo Fujita Date: Fri, 31 Jul 2026 21:36:08 +0900 Subject: [PATCH] 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. --- .gitignore | 5 + README.md | 9 + web/Makefile | 44 ++++ web/README.md | 59 +++++ web/gen_sample.py | 108 +++++++++ web/index.html | 28 +++ web/loader.c | 548 ++++++++++++++++++++++++++++++++++++++++++++++ web/main.js | 309 ++++++++++++++++++++++++++ web/style.css | 91 ++++++++ 9 files changed, 1201 insertions(+) create mode 100644 web/Makefile create mode 100644 web/README.md create mode 100644 web/gen_sample.py create mode 100644 web/index.html create mode 100644 web/loader.c create mode 100644 web/main.js create mode 100644 web/style.css diff --git a/.gitignore b/.gitignore index e140f7c..f5b75d3 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 95b2972..36b13b6 100644 --- a/README.md +++ b/README.md @@ -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`, diff --git a/web/Makefile b/web/Makefile new file mode 100644 index 0000000..53d92f2 --- /dev/null +++ b/web/Makefile @@ -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) diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..1924a2b --- /dev/null +++ b/web/README.md @@ -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 | diff --git a/web/gen_sample.py b/web/gen_sample.py new file mode 100644 index 0000000..7661311 --- /dev/null +++ b/web/gen_sample.py @@ -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(" + + + + +tinygltf v3 — three.js WASM demo + + + +
+

tinygltf v3 (C) + three.js

+
+ + + +
+
+
+
+

Drop a .glb / .gltf file here, or use Open glTF/GLB…

+

Note: assets must be self-contained (GLB chunk or embedded data URI).

+
+ +
+
Loading WASM module…
+ + + diff --git a/web/loader.c b/web/loader.c new file mode 100644 index 0000000..e888a5f --- /dev/null +++ b/web/loader.c @@ -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 +#include +#include +#include + +#include "tiny_gltf_v3.h" + +#if defined(__EMSCRIPTEN__) +#include +#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; +} diff --git a/web/main.js b/web/main.js new file mode 100644 index 0000000..49b21df --- /dev/null +++ b/web/main.js @@ -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(); diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..fbc2708 --- /dev/null +++ b/web/style.css @@ -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; +}