├── .github
├── FUNDING.yml
├── codecov.yml
├── pull_request_template.md
└── workflows
│ └── ci.yml
├── .gitignore
├── test
├── tsconfig.json
├── MeshSubject.test.ts
├── SceneSubject.test.ts
├── InstancedMeshSubject.test.ts
├── PrimitiveSubject.test.ts
├── SkinSubject.test.ts
├── TextureSubject.test.ts
├── NodeSubject.test.ts
├── LightSubject.test.ts
├── MaterialSubject.test.ts
└── DocumentView.test.ts
├── assets
├── DamagedHelmet.glb
├── view_architecture.png
├── royal_esplanade_1k.hdr
└── view_architecture.json
├── src
├── observers
│ ├── index.ts
│ ├── RefMapObserver.ts
│ ├── RefListObserver.ts
│ └── RefObserver.ts
├── pools
│ ├── index.ts
│ ├── SingleUserPool.ts
│ ├── Pool.ts
│ ├── TexturePool.ts
│ └── MaterialPool.ts
├── index.ts
├── subjects
│ ├── index.ts
│ ├── ExtensionSubject.ts
│ ├── SceneSubject.ts
│ ├── AccessorSubject.ts
│ ├── TextureSubject.ts
│ ├── MeshSubject.ts
│ ├── LightSubject.ts
│ ├── SkinSubject.ts
│ ├── InstancedMeshSubject.ts
│ ├── Subject.ts
│ ├── NodeSubject.ts
│ ├── PrimitiveSubject.ts
│ └── MaterialSubject.ts
├── constants.ts
├── utils
│ ├── Observable.ts
│ └── index.ts
├── DocumentView.ts
├── ImageProvider.ts
└── DocumentViewImpl.ts
├── .prettierrc.json
├── examples
├── vite.config.js
├── 1-model.html
├── 2-material.html
├── index.html
├── 3-diff.html
├── stats-pane.ts
├── style.css
├── util.ts
├── dropzone.ts
├── 1-model.ts
├── 2-material.ts
├── 3-diff.ts
└── material-pane.ts
├── renovate.json
├── tsconfig.json
├── README.md
├── eslint.config.js
├── LICENSE.md
├── CONTRIBUTING.md
├── CONTRIBUTOR_LICENSE_AGREEMENT.md
└── package.json
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [donmccurdy]
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | coverage
3 | dist
4 | .cache
5 |
--------------------------------------------------------------------------------
/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./**/*.test.ts"],
3 | "extends": "../tsconfig.json"
4 | }
5 |
--------------------------------------------------------------------------------
/assets/DamagedHelmet.glb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/donmccurdy/glTF-Transform-View/HEAD/assets/DamagedHelmet.glb
--------------------------------------------------------------------------------
/assets/view_architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/donmccurdy/glTF-Transform-View/HEAD/assets/view_architecture.png
--------------------------------------------------------------------------------
/assets/royal_esplanade_1k.hdr:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/donmccurdy/glTF-Transform-View/HEAD/assets/royal_esplanade_1k.hdr
--------------------------------------------------------------------------------
/src/observers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './RefObserver';
2 | export * from './RefListObserver';
3 | export * from './RefMapObserver';
4 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "useTabs": true,
4 | "tabWidth": 4,
5 | "printWidth": 120,
6 | "bracketSpacing": true
7 | }
8 |
--------------------------------------------------------------------------------
/src/pools/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Pool';
2 | export * from './TexturePool';
3 | export * from './MaterialPool';
4 | export * from './SingleUserPool';
5 |
--------------------------------------------------------------------------------
/examples/vite.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | root: 'examples',
3 | publicDir: '../assets',
4 | resolve: { alias: { '@gltf-transform/view': '../' } },
5 | };
6 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { DocumentView } from './DocumentView';
2 | export { DefaultImageProvider as ImageProvider, NullImageProvider } from './ImageProvider';
3 |
--------------------------------------------------------------------------------
/examples/1-model.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | @gltf-transform/view : examples : 1-model.html
5 |
6 |
7 |
8 |
9 | ⏮ back
10 |
11 |
12 |
--------------------------------------------------------------------------------
/examples/2-material.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | @gltf-transform/view : examples : 2-material.html
5 |
6 |
7 |
8 |
9 | ⏮ back
10 |
11 |
12 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["github>donmccurdy/renovate-config"],
3 | "packageRules": [
4 | {
5 | "description": "Tests failing in Ava",
6 | "matchPackageNames": ["jsdom"],
7 | "enabled": false
8 | },
9 | {
10 | "description": "Requires updates to tweakpane-plugin-thumbnail-list",
11 | "matchPackageNames": ["tweakpane"],
12 | "enabled": false
13 | }
14 | ]
15 | }
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["src/**/*.ts"],
3 | "compilerOptions": {
4 | "paths": {
5 | "@gltf-transform/view": ["./"]
6 | },
7 | "esModuleInterop": true,
8 | "moduleResolution": "node",
9 | "lib": ["es2020", "dom"],
10 | "target": "es2020",
11 | "module": "esnext",
12 | "declaration": true,
13 | "typeRoots": ["node_modules/@types"],
14 | "strict": true,
15 | "noImplicitAny": false
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/subjects/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Subject';
2 | export * from './AccessorSubject';
3 | export * from './ExtensionSubject';
4 | export * from './InstancedMeshSubject';
5 | export * from './LightSubject';
6 | export * from './MaterialSubject';
7 | export * from './MeshSubject';
8 | export * from './NodeSubject';
9 | export * from './PrimitiveSubject';
10 | export * from './SceneSubject';
11 | export * from './SkinSubject';
12 | export * from './TextureSubject';
13 |
--------------------------------------------------------------------------------
/.github/codecov.yml:
--------------------------------------------------------------------------------
1 | # Based on https://docs.codecov.com/docs/codecov-yaml#default-yaml
2 | codecov:
3 | require_ci_to_pass: yes
4 |
5 | comment: false
6 |
7 | coverage:
8 | precision: 2
9 | round: down
10 | range: "50...95"
11 | status:
12 | patch: off
13 | project:
14 | default:
15 | target: 80%
16 | threshold: 1%
17 |
18 | parsers:
19 | gcov:
20 | branch_detection:
21 | conditional: yes
22 | loop: yes
23 | method: no
24 | macro: no
25 |
--------------------------------------------------------------------------------
/src/subjects/ExtensionSubject.ts:
--------------------------------------------------------------------------------
1 | import type { ExtensionProperty as ExtensionPropertyDef } from '@gltf-transform/core';
2 | import type { DocumentViewImpl } from '../DocumentViewImpl';
3 | import { Subject } from './Subject';
4 |
5 | /** @internal */
6 | export class ExtensionSubject extends Subject {
7 | constructor(documentView: DocumentViewImpl, def: ExtensionPropertyDef) {
8 | super(documentView, def, def, documentView.extensionPool);
9 | }
10 | update() {}
11 | }
12 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ### Description
2 |
3 | _Describe the purpose of this pull request._
4 |
5 | ### Related issues
6 |
7 | _Links to any related bugs or feature requests._
8 |
9 | ### Contributor license agreement
10 |
11 | _Read the [contributor license agreement](https://github.com/donmccurdy/glTF-Transform-View/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
12 | If you agree to the terms of the agreement, check the box below._
13 |
14 | - [ ] I have read the contributor license agreement in full, and I am contributing this code under the terms of the agreement.
15 |
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | @gltf-transform/view : examples : index.html
5 |
16 |
17 |
18 | @gltf-transform/view : examples
19 |
24 |
25 |
--------------------------------------------------------------------------------
/examples/3-diff.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | @gltf-transform/view : examples : 3-diff.html
5 |
6 |
7 |
8 |
9 | ⏮ back
10 |
11 |
Select .GLTF or .GLB file.
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/examples/stats-pane.ts:
--------------------------------------------------------------------------------
1 | import { WebGLRenderer } from 'three';
2 | import { Pane } from 'tweakpane';
3 |
4 | export function createStatsPane(renderer: WebGLRenderer, pane: Pane) {
5 | const stats = {info: ''};
6 | const monitorFolder = pane.addFolder({index: 0, title: 'Monitor'})
7 | monitorFolder.addMonitor(stats, 'info', {bufferSize: 1, multiline: true, lineCount: 3});
8 |
9 |
10 |
11 | return () => {
12 | const info = renderer.info;
13 | stats.info = `
14 | programs ${info.programs.length}
15 | geometries ${info.memory.geometries}
16 | textures ${info.memory.textures}
17 | `.trim();
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | import {
2 | BufferGeometry,
3 | DirectionalLight,
4 | Line,
5 | LineLoop,
6 | LineSegments,
7 | Material,
8 | Mesh,
9 | PointLight,
10 | Points,
11 | SkinnedMesh,
12 | SpotLight,
13 | } from 'three';
14 |
15 | export type Subscription = () => void;
16 |
17 | export type MeshLike =
18 | | Mesh
19 | | SkinnedMesh
20 | | Points
21 | | Line
22 | | LineSegments
23 | | LineLoop;
24 |
25 | export type LightLike = PointLight | SpotLight | DirectionalLight;
26 |
27 | export interface THREEObject {
28 | name: string;
29 | type: string;
30 | }
31 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: build
2 | on: [push]
3 | jobs:
4 | build:
5 | runs-on: ubuntu-latest
6 | strategy:
7 | matrix:
8 | node-version: [20.x]
9 | env:
10 | CI: true
11 | steps:
12 | # Setup.
13 | - uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4
14 | - name: Use Node.js ${{ matrix.node-version }}
15 | uses: actions/setup-node@v4
16 | with:
17 | node-version: ${{ matrix.node-version }}
18 | - run: yarn install
19 | - run: yarn dist
20 | - run: yarn test
21 | - run: yarn lint
22 |
23 | # Coverage.
24 | - run: yarn coverage
25 | - run: yarn coverage:report
26 | - uses: codecov/codecov-action@v4
27 | with:
28 | files: coverage/coverage.lcov
29 |
--------------------------------------------------------------------------------
/src/utils/Observable.ts:
--------------------------------------------------------------------------------
1 | import { Subscription } from '../constants';
2 |
3 | export class Observable {
4 | public value: Value;
5 | private _subscriber: ((next: Value, prev: Value) => void) | null = null;
6 |
7 | constructor(value: Value) {
8 | this.value = value;
9 | }
10 |
11 | public subscribe(subscriber: (next: Value, prev: Value) => void): Subscription {
12 | if (this._subscriber) {
13 | throw new Error('Observable: Limit one subscriber per Observable.');
14 | }
15 |
16 | this._subscriber = subscriber;
17 | return () => (this._subscriber = null);
18 | }
19 |
20 | public next(value: Value) {
21 | const prevValue = this.value;
22 | this.value = value;
23 | if (this._subscriber) {
24 | this._subscriber(this.value, prevValue);
25 | }
26 | }
27 |
28 | public dispose() {
29 | this._subscriber = null;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @gltf-transform/view
2 |
3 | > ⚠️ Package moved to the [glTF-Transform monorepo](https://github.com/donmccurdy/glTF-Transform).
4 |
5 | ## Overview
6 |
7 | Creates three.js objects from a glTF Transform [Document](https://gltf-transform.donmccurdy.com/classes/core.document.html), then keeps the three.js scene graph updated — in realtime ⚡️ — as changes are made to the Document. Combined with import/export using [WebIO](https://gltf-transform.donmccurdy.com/classes/core.platformio.html), `@gltf-transform/view` provides a lossless workflow to load, view, edit, and export glTF assets. It's meant for editor-like applications on the web. Unlike using [THREE.GLTFExporter](https://threejs.org/docs/index.html#examples/en/loaders/GLTFExporter), any glTF features that three.js doesn't support won't be lost, they just aren't rendered in the preview.
8 |
9 | ## License
10 |
11 | Published under [Blue Oak Model License 1.0.0](/LICENSE.md).
12 |
--------------------------------------------------------------------------------
/examples/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --tp-plugin-thumbnail-list-thumb-size: 64px;
3 | --tp-plugin-thumbnail-list-width: 325px;
4 | --tp-plugin-thumbnail-list-height: 300px;
5 | }
6 |
7 | html, body {
8 | padding: 0;
9 | margin: 0;
10 | }
11 |
12 | .back {
13 | position: fixed;
14 | top: 1em;
15 | left: 1em;
16 | z-index: 1;
17 | color: #FFFFFF;
18 | text-decoration: none;
19 | opacity: 0.75;
20 | }
21 |
22 | .back:hover {
23 | opacity: 1;
24 | }
25 |
26 | /* Dropzone */
27 |
28 | .dropzone-placeholder {
29 | width: 400px;
30 | height: 200px;
31 | position: absolute;
32 | top: calc(50% - 100px);
33 | left: calc(50% - 200px);
34 | background: rgba(255, 255, 255, 0.75);
35 | border-radius: 8px;
36 | display: flex;
37 | flex-direction: column;
38 | justify-content: center;
39 | padding: 3em;
40 | box-sizing: border-box;
41 | }
42 |
43 | .dropzone-placeholder p {
44 | margin-top: 0;
45 | }
46 |
47 | /* Tweakpane */
48 |
49 | .tp-dfwv {
50 | width: 350px !important;
51 | }
52 |
53 | .tp-colv_t .tp-txtv {
54 | position: relative;
55 | }
56 |
57 | .tp-colv_t .tp-txtv::after {
58 | content: 'sRGB';
59 | position: absolute;
60 | right: 0.5em;
61 | top: 0.3em;
62 | color: #888;
63 | font-size: 1em;
64 | }
65 |
--------------------------------------------------------------------------------
/src/subjects/SceneSubject.ts:
--------------------------------------------------------------------------------
1 | import { Group, Object3D } from 'three';
2 | import type { Node as NodeDef, Scene as SceneDef } from '@gltf-transform/core';
3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
4 | import { Subject } from './Subject';
5 | import { RefListObserver } from '../observers';
6 |
7 | /** @internal */
8 | export class SceneSubject extends Subject {
9 | protected children = new RefListObserver('children', this._documentView);
10 |
11 | constructor(documentView: DocumentViewSubjectAPI, def: SceneDef) {
12 | super(documentView, def, documentView.scenePool.requestBase(new Group()), documentView.scenePool);
13 | this.children.subscribe((nextChildren, prevChildren) => {
14 | if (prevChildren.length) this.value.remove(...prevChildren);
15 | if (nextChildren.length) this.value.add(...nextChildren);
16 | this.publishAll();
17 | });
18 | }
19 |
20 | update() {
21 | const def = this.def;
22 | const target = this.value;
23 |
24 | if (def.getName() !== target.name) {
25 | target.name = def.getName();
26 | }
27 |
28 | this.children.update(def.listChildren());
29 | }
30 |
31 | dispose() {
32 | this.children.dispose();
33 | super.dispose();
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/subjects/AccessorSubject.ts:
--------------------------------------------------------------------------------
1 | import { BufferAttribute } from 'three';
2 | import { Accessor as AccessorDef } from '@gltf-transform/core';
3 | import type { DocumentViewImpl } from '../DocumentViewImpl';
4 | import { Subject } from './Subject';
5 | import { ValuePool } from '../pools';
6 |
7 | /** @internal */
8 | export class AccessorSubject extends Subject {
9 | constructor(documentView: DocumentViewImpl, def: AccessorDef) {
10 | super(
11 | documentView,
12 | def,
13 | AccessorSubject.createValue(def, documentView.accessorPool),
14 | documentView.accessorPool,
15 | );
16 | }
17 |
18 | private static createValue(def: AccessorDef, pool: ValuePool) {
19 | return pool.requestBase(new BufferAttribute(def.getArray()!, def.getElementSize(), def.getNormalized()));
20 | }
21 |
22 | update() {
23 | const def = this.def;
24 | const value = this.value;
25 |
26 | if (
27 | def.getArray() !== value.array ||
28 | def.getElementSize() !== value.itemSize ||
29 | def.getNormalized() !== value.normalized
30 | ) {
31 | this.pool.releaseBase(value);
32 | this.value = AccessorSubject.createValue(def, this.pool);
33 | } else {
34 | value.needsUpdate = true;
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { MeshStandardMaterial } from 'three';
2 |
3 | export * from './Observable';
4 |
5 | export function eq(a: number[], b: number[]): boolean {
6 | if (a.length !== b.length) return false;
7 | for (let i = 0; i < a.length; i++) {
8 | if (a[i] !== b[i]) return false;
9 | }
10 | return true;
11 | }
12 |
13 | // https://github.com/KhronosGroup/glTF/blob/master/specification/2.0/README.md#default-material
14 | export const DEFAULT_MATERIAL = new MeshStandardMaterial({
15 | name: '__DefaultMaterial',
16 | color: 0xffffff,
17 | roughness: 1.0,
18 | metalness: 1.0,
19 | });
20 |
21 | export function semanticToAttributeName(semantic: string): string {
22 | switch (semantic) {
23 | case 'POSITION':
24 | return 'position';
25 | case 'NORMAL':
26 | return 'normal';
27 | case 'TANGENT':
28 | return 'tangent';
29 | case 'COLOR_0':
30 | return 'color';
31 | case 'JOINTS_0':
32 | return 'skinIndex';
33 | case 'WEIGHTS_0':
34 | return 'skinWeight';
35 | case 'TEXCOORD_0':
36 | return 'uv';
37 | case 'TEXCOORD_1':
38 | return 'uv1';
39 | case 'TEXCOORD_2':
40 | return 'uv2';
41 | case 'TEXCOORD_3':
42 | return 'uv3';
43 | default:
44 | return '_' + semantic.toLowerCase();
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/test/MeshSubject.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { Document } from '@gltf-transform/core';
3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view';
4 |
5 | const imageProvider = new NullImageProvider();
6 |
7 | test('MeshSubject', async t => {
8 | const document = new Document();
9 | const position = document.createAccessor()
10 | .setType('VEC3')
11 | .setArray(new Float32Array([
12 | 0, 0, 0,
13 | 0, 0, 1,
14 | 0, 1, 1,
15 | 0, 1, 0,
16 | 0, 0, 0,
17 | ]));
18 | const primDef = document.createPrimitive()
19 | .setAttribute('POSITION', position);
20 | const meshDef = document.createMesh()
21 | .setName('MyMesh')
22 | .addPrimitive(primDef);
23 |
24 | const documentView = new DocumentView(document, {imageProvider});
25 | const mesh = documentView.view(meshDef);
26 |
27 | t.is(mesh.name, 'MyMesh', 'mesh → name');
28 |
29 | meshDef.setName('MyMeshRenamed');
30 | t.is(mesh.name, 'MyMeshRenamed', 'mesh → name (2)');
31 |
32 | t.is(mesh.children[0].type, 'Mesh', 'mesh → prim (initial)');
33 |
34 | meshDef.removePrimitive(primDef);
35 | t.is(mesh.children.length, 0, 'mesh → prim (remove)');
36 |
37 | meshDef.addPrimitive(primDef);
38 | t.is(mesh.children.length, 1, 'mesh → prim (add)');
39 | });
40 |
--------------------------------------------------------------------------------
/src/subjects/TextureSubject.ts:
--------------------------------------------------------------------------------
1 | import { Texture } from 'three';
2 | import { Texture as TextureDef } from '@gltf-transform/core';
3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
4 | import { Subject } from './Subject';
5 | import { LOADING_TEXTURE } from '../ImageProvider';
6 |
7 | /** @internal */
8 | export class TextureSubject extends Subject {
9 | private _image: ArrayBuffer | null = null;
10 |
11 | constructor(documentView: DocumentViewSubjectAPI, def: TextureDef) {
12 | super(documentView, def, LOADING_TEXTURE, documentView.texturePool);
13 | }
14 |
15 | update() {
16 | const def = this.def;
17 | const value = this.value;
18 |
19 | if (def.getName() !== value.name) {
20 | value.name = def.getName();
21 | }
22 |
23 | const image = def.getImage() as ArrayBuffer;
24 | if (image !== this._image) {
25 | this._image = image;
26 | if (this.value !== LOADING_TEXTURE) {
27 | this.pool.releaseBase(this.value);
28 | }
29 | this._documentView.imageProvider.getTexture(def).then((texture) => {
30 | this.value = this.pool.requestBase(texture);
31 | this.publishAll(); // TODO(perf): Might be wasting cycles here.
32 | });
33 | }
34 | }
35 |
36 | dispose() {
37 | super.dispose();
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | // @ts-check
2 | import eslint from "@eslint/js";
3 | import prettier from "eslint-config-prettier";
4 | import tseslint from "typescript-eslint";
5 |
6 | export default tseslint.config(
7 | eslint.configs.recommended,
8 | ...tseslint.configs.recommended,
9 | prettier,
10 | {
11 | rules: {
12 | "@typescript-eslint/no-explicit-any": ["off"], // TODO
13 | "@typescript-eslint/no-non-null-assertion": ["off"],
14 | "@typescript-eslint/no-this-alias": ["off"],
15 | "@typescript-eslint/no-unused-vars": [
16 | "warn",
17 | { argsIgnorePattern: "^_" },
18 | ],
19 | "@typescript-eslint/no-use-before-define": "off",
20 | "@typescript-eslint/no-var-requires": ["off"],
21 | "array-bracket-spacing": ["warn"],
22 | "comma-spacing": ["warn"],
23 | "max-len": [
24 | "warn",
25 | {
26 | code: 120,
27 | tabWidth: 4,
28 | ignoreUrls: true,
29 | ignorePattern: "^import|^export",
30 | },
31 | ],
32 | "no-irregular-whitespace": ["warn"],
33 | "space-infix-ops": ["warn"],
34 | eqeqeq: ["warn", "smart"],
35 | semi: ["error"],
36 | },
37 | },
38 | {
39 | files: ["test/**/*.ts"],
40 | rules: {
41 | "@typescript-eslint/no-explicit-any": "off",
42 | },
43 | },
44 | );
45 |
--------------------------------------------------------------------------------
/test/SceneSubject.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { Document } from '@gltf-transform/core';
3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view';
4 |
5 | const imageProvider = new NullImageProvider();
6 |
7 | test('SceneBinding', async t => {
8 | const document = new Document();
9 | let nodeDef;
10 | const sceneDef = document.createScene('MyScene')
11 | .addChild(document.createNode('Node1'))
12 | .addChild(nodeDef = document.createNode('Node2'))
13 | .addChild(document.createNode('Node3'));
14 | nodeDef.addChild(document.createNode('Node4'));
15 |
16 | const documentView = new DocumentView(document, {imageProvider});
17 | const scene = documentView.view(sceneDef);
18 |
19 | t.is(scene.name, 'MyScene', 'scene → name');
20 | sceneDef.setName('MySceneRenamed');
21 | t.is(scene.name, 'MySceneRenamed', 'scene → name (renamed)');
22 | t.is(scene.children.length, 3, 'scene → children → 3');
23 |
24 | t.is(scene.children[1].children[0].name, 'Node4', 'scene → ... → grandchild');
25 | nodeDef.listChildren()[0].dispose();
26 | t.is(scene.children[1].children.length, 0, 'scene → ... → grandchild (dispose)');
27 |
28 | nodeDef.dispose();
29 | t.is(scene.children.length, 2, 'scene → children → 2');
30 | sceneDef.removeChild(sceneDef.listChildren()[0]);
31 | t.is(scene.children.length, 1, 'scene → children → 1');
32 | });
33 |
--------------------------------------------------------------------------------
/src/subjects/MeshSubject.ts:
--------------------------------------------------------------------------------
1 | import { Group } from 'three';
2 | import { Mesh as MeshDef, Primitive as PrimitiveDef } from '@gltf-transform/core';
3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
4 | import { Subject } from './Subject';
5 | import { RefListObserver } from '../observers';
6 | import { MeshLike } from '../constants';
7 | import { SingleUserParams, SingleUserPool } from '../pools';
8 |
9 | /** @internal */
10 | export class MeshSubject extends Subject {
11 | protected primitives = new RefListObserver(
12 | 'primitives',
13 | this._documentView,
14 | ).setParamsFn(() => SingleUserPool.createParams(this.def));
15 |
16 | constructor(documentView: DocumentViewSubjectAPI, def: MeshDef) {
17 | super(documentView, def, documentView.meshPool.requestBase(new Group()), documentView.meshPool);
18 |
19 | this.primitives.subscribe((nextPrims, prevPrims) => {
20 | if (prevPrims.length) this.value.remove(...prevPrims);
21 | if (nextPrims.length) this.value.add(...nextPrims);
22 | this.publishAll();
23 | });
24 | }
25 |
26 | update() {
27 | const def = this.def;
28 | const value = this.value;
29 |
30 | if (def.getName() !== value.name) {
31 | value.name = def.getName();
32 | }
33 |
34 | this.primitives.update(def.listPrimitives());
35 | }
36 |
37 | dispose() {
38 | this.primitives.dispose();
39 | super.dispose();
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/test/InstancedMeshSubject.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { Document } from '@gltf-transform/core';
3 | import { EXTMeshGPUInstancing } from '@gltf-transform/extensions';
4 | import { DocumentView, NullImageProvider } from '@gltf-transform/view';
5 | import { Group, InstancedMesh, Object3D } from 'three';
6 |
7 | const imageProvider = new NullImageProvider();
8 |
9 | test('InstancedMeshSubject', async (t) => {
10 | const document = new Document();
11 | const batchExt = document.createExtension(EXTMeshGPUInstancing);
12 | const batchTranslation = document.createAccessor()
13 | .setType('VEC3')
14 | .setArray(new Float32Array([0, 0, 0, 10, 0, 0, 20, 0, 0]));
15 | const batchDef = batchExt.createInstancedMesh()
16 | .setAttribute('TRANSLATION', batchTranslation);
17 | const position = document.createAccessor()
18 | .setType('VEC3')
19 | .setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
20 | const primDef = document.createPrimitive().setAttribute('POSITION', position);
21 | const meshDef = document.createMesh().addPrimitive(primDef);
22 | const nodeDef = document.createNode()
23 | .setMesh(meshDef)
24 | .setExtension('EXT_mesh_gpu_instancing', batchDef);
25 |
26 | const documentView = new DocumentView(document, {imageProvider});
27 | const node = documentView.view(nodeDef);
28 | const group = node.children[0] as Group;
29 | const mesh = group.children[0] as InstancedMesh;
30 |
31 | t.deepEqual(node.children.map(toType), ['Group'], 'node.children → [Group]');
32 | t.deepEqual(group.children.map(toType), ['Mesh'], 'group.children → [Mesh]');
33 | t.is(mesh.isInstancedMesh, true, 'isInstancedMesh → true');
34 | t.is(mesh.count, 3, 'count → 3');
35 | });
36 |
37 | function toType(object: Object3D): string {
38 | return object.type;
39 | }
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # Blue Oak Model License
2 |
3 | Version 1.0.0
4 |
5 | ## Purpose
6 |
7 | This license gives everyone as much permission to work with
8 | this software as possible, while protecting contributors
9 | from liability.
10 |
11 | ## Acceptance
12 |
13 | In order to receive this license, you must agree to its
14 | rules. The rules of this license are both obligations
15 | under that agreement and conditions to your license.
16 | You must not do anything with this software that triggers
17 | a rule that you cannot or will not follow.
18 |
19 | ## Copyright
20 |
21 | Each contributor licenses you to do everything with this
22 | software that would otherwise infringe that contributor's
23 | copyright in it.
24 |
25 | ## Notices
26 |
27 | You must ensure that everyone who gets a copy of
28 | any part of this software from you, with or without
29 | changes, also gets the text of this license or a link to
30 | .
31 |
32 | ## Excuse
33 |
34 | If anyone notifies you in writing that you have not
35 | complied with [Notices](#notices), you can keep your
36 | license by taking all practical steps to comply within 30
37 | days after the notice. If you do not do so, your license
38 | ends immediately.
39 |
40 | ## Patent
41 |
42 | Each contributor licenses you to do everything with this
43 | software that would otherwise infringe any patent claims
44 | they can license or become able to license.
45 |
46 | ## Reliability
47 |
48 | No contributor can revoke this license.
49 |
50 | ## No Liability
51 |
52 | ***As far as the law allows, this software comes as is,
53 | without any warranty or condition, and no contributor
54 | will be liable to anyone for any damages related to this
55 | software or this license, under any kind of legal claim.***
56 |
--------------------------------------------------------------------------------
/src/pools/SingleUserPool.ts:
--------------------------------------------------------------------------------
1 | import { Property as PropertyDef, Mesh as MeshDef, Node as NodeDef, uuid } from '@gltf-transform/core';
2 | import { Object3D } from 'three';
3 | import { LightLike } from '../constants';
4 | import { Pool } from './Pool';
5 |
6 | export interface SingleUserParams {
7 | id: string;
8 | }
9 |
10 | /** @internal */
11 | export class SingleUserPool extends Pool {
12 | private static _parentIDs = new WeakMap();
13 |
14 | /** Generates a unique Object3D for every parent. */
15 | static createParams(property: MeshDef | NodeDef): SingleUserParams {
16 | const id = this._parentIDs.get(property) || uuid();
17 | this._parentIDs.set(property, id);
18 | return { id };
19 | }
20 |
21 | requestVariant(base: T, params: SingleUserParams): T {
22 | return this._request(this._createVariant(base, params));
23 | }
24 |
25 | protected _createVariant(srcObject: T, _params: SingleUserParams): T {
26 | // With a deep clone of a NodeDef or MeshDef value, we're cloning
27 | // any PrimitiveDef values (e.g. Mesh, Lines, Points) within it.
28 | // Record the new outputs.
29 | const dstObject = srcObject.clone();
30 | parallelTraverse(srcObject, dstObject, (base, variant) => {
31 | if (base === srcObject) return; // Skip root; recorded elsewhere.
32 | if ((srcObject as unknown as LightLike).isLight) return; // Skip light target.
33 | this.documentView.recordOutputVariant(base, variant);
34 | });
35 | return dstObject;
36 | }
37 |
38 | protected _updateVariant(_srcObject: T, _dstObject: T): T {
39 | throw new Error('Not implemented');
40 | }
41 | }
42 |
43 | function parallelTraverse(a: Object3D, b: Object3D, callback: (a: Object3D, b: Object3D) => void) {
44 | callback(a, b);
45 | for (let i = 0; i < a.children.length; i++) {
46 | parallelTraverse(a.children[i], b.children[i], callback);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/test/PrimitiveSubject.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { Document, Primitive as PrimitiveDef } from '@gltf-transform/core';
3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view';
4 |
5 | const imageProvider = new NullImageProvider();
6 |
7 | test('PrimitiveSubject', async t => {
8 | const document = new Document();
9 | const position = document.createAccessor()
10 | .setType('VEC3')
11 | .setArray(new Float32Array([
12 | 0, 0, 0,
13 | 0, 0, 1,
14 | 0, 1, 1,
15 | 0, 1, 0,
16 | 0, 0, 0,
17 | ]));
18 | const materialDef = document.createMaterial('MyMaterial');
19 | const primDef = document.createPrimitive()
20 | .setAttribute('POSITION', position)
21 | .setMaterial(materialDef);
22 |
23 | const documentView = new DocumentView(document, {imageProvider});
24 | let prim = documentView.view(primDef);
25 | const geometry = prim.geometry;
26 |
27 | const disposed = new Set();
28 | geometry.addEventListener('dispose', () => disposed.add(geometry));
29 |
30 | t.is(prim.type, 'Mesh', 'Mesh');
31 |
32 | primDef.setMode(PrimitiveDef.Mode.POINTS);
33 | prim = documentView.view(primDef);
34 |
35 | t.is(prim.type, 'Points', 'Points');
36 |
37 | primDef.setMode(PrimitiveDef.Mode.LINES);
38 | prim = documentView.view(primDef);
39 |
40 | t.is(prim.type, 'LineSegments', 'LineSegments');
41 |
42 | primDef.setMode(PrimitiveDef.Mode.LINE_LOOP);
43 | prim = documentView.view(primDef);
44 |
45 | t.is(prim.type, 'LineLoop', 'LineLoop');
46 |
47 | primDef.setMode(PrimitiveDef.Mode.LINE_STRIP);
48 | prim = documentView.view(primDef);
49 |
50 | t.is(prim.type, 'Line', 'Line');
51 |
52 | t.is(prim.material.name, 'MyMaterial', 'prim.material → material');
53 |
54 | primDef.setMaterial(null);
55 |
56 | t.is(prim.material.name, '__DefaultMaterial', 'prim.material → null');
57 |
58 | t.is(disposed.size, 0, 'preserve geometry');
59 |
60 | primDef.dispose();
61 |
62 | t.is(disposed.size, 1, 'dispose geometry');
63 | });
64 |
--------------------------------------------------------------------------------
/examples/util.ts:
--------------------------------------------------------------------------------
1 | import { WebIO } from '@gltf-transform/core';
2 | import { PMREMGenerator, REVISION, Texture, WebGLRenderer } from 'three';
3 | import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
4 | import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader';
5 | import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader';
6 | import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer';
7 | import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
8 |
9 | const TRANSCODER_PATH = `https://unpkg.com/three@0.${REVISION}.x/examples/jsm/libs/basis/`;
10 |
11 | // await MeshoptDecoder.ready;
12 | // await MeshoptEncoder.ready;
13 |
14 | let _ktx2Loader: KTX2Loader;
15 | export function createKTX2Loader() {
16 | if (_ktx2Loader) return _ktx2Loader;
17 | const renderer = new WebGLRenderer();
18 | const loader = new KTX2Loader()
19 | .detectSupport(renderer)
20 | .setTranscoderPath(TRANSCODER_PATH);
21 | renderer.dispose();
22 | return (_ktx2Loader = loader);
23 | }
24 |
25 | let _gltfLoader: GLTFLoader;
26 | export function createGLTFLoader() {
27 | if (_gltfLoader) return _gltfLoader;
28 | const loader = new GLTFLoader()
29 | .setMeshoptDecoder(MeshoptDecoder)
30 | .setKTX2Loader(createKTX2Loader());
31 | return (_gltfLoader = loader);
32 | }
33 |
34 | let _io: WebIO;
35 | export function createIO() {
36 | if (_io) return _io;
37 | const io = new WebIO()
38 | .registerExtensions(ALL_EXTENSIONS)
39 | .registerDependencies({
40 | 'meshopt.encoder': MeshoptEncoder,
41 | 'meshopt.decoder': MeshoptDecoder,
42 | });
43 | return (_io = io);
44 | }
45 |
46 | export function createEnvironment(renderer: WebGLRenderer): Promise {
47 | const pmremGenerator = new PMREMGenerator(renderer);
48 | pmremGenerator.compileEquirectangularShader();
49 |
50 | return new Promise((resolve, reject) => {
51 | new RGBELoader()
52 | .load( './royal_esplanade_1k.hdr', ( texture ) => {
53 | const envMap = pmremGenerator.fromEquirectangular( texture ).texture;
54 | texture.dispose();
55 | pmremGenerator.dispose();
56 | resolve(envMap);
57 | }, undefined, reject );
58 | }) as Promise;
59 | }
60 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Quickstart
4 |
5 | ```shell
6 | # Install dependencies.
7 | yarn
8 |
9 | # Build source, watch for changes.
10 | yarn watch
11 |
12 | # Build source, watch for changes, and run examples.
13 | yarn dev
14 |
15 | # Run tests.
16 | yarn test
17 | ```
18 |
19 | ## Concepts
20 |
21 | The project is designed around a few common object types, loosely based on reactive programming patterns.
22 |
23 | - **Subject:** Each glTF definition (e.g. `Material`) is bound to a single Subject (e.g. `MaterialSubject`).
24 | The Subject is responsible for receiving change events published by the definition, generating a
25 | derived three.js object (e.g. `THREE.Material`), and publishing the new value to all Observers. More
26 | precisely, this is a [*BehaviorSubject*](https://reactivex.io/documentation/subject.html), which holds
27 | a single current value at any given time.
28 | - **Observer:** An Observer is subscribed to the values published by a particular Subject, and
29 | passes those events along to a parent — usually another Subject. For example, a MaterialSubject
30 | subscribes to updates from a TextureSubject using an Observer. Observers are parameterized:
31 | for example, a single Texture may be used by many Materials, with different offset/scale/encoding
32 | parameters in each. The TextureSubject treats each of these Observers as a different "output", and
33 | uses the parameters associated with the Observer to publish the appropriate value. This library
34 | implements three observer types: `RefObserver`, `RefListObserver`, and `RefMapObserver`; the latter
35 | two are collections of `RefObservers` used to collate events from multiple Subjects (e.g. lists of Nodes).
36 | - **Pool:** As Subjects publish many variations of the same values to Observers, it's important to
37 | allocate those variations efficiently, reuse instances where possible, and clean up unused
38 | instances. That bookkeeping is assigned to Pools (not a Reactive concept).
39 |
40 | The diagram below shows a subset of the data flow sequence connecting Texture, Material, Primitive, and Node
41 | data types.
42 |
43 | 
44 |
--------------------------------------------------------------------------------
/test/SkinSubject.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { Document } from '@gltf-transform/core';
3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view';
4 | import { Bone, Mesh, SkinnedMesh } from 'three';
5 |
6 | const imageProvider = new NullImageProvider();
7 |
8 | test('SkinSubject', async t => {
9 | const document = new Document();
10 | const positionDef = document.createAccessor('POSITION')
11 | .setArray(new Float32Array([0, 0, 0, 1, 1, 1, 2, 2, 2]))
12 | .setType('VEC3');
13 | const jointsDef = document.createAccessor('JOINTS_0')
14 | .setArray(new Uint16Array([0, 1, 0, 0]))
15 | .setType('VEC4');
16 | const weightsDef = document.createAccessor('WEIGHTS_0')
17 | .setArray(new Float32Array([0.5, 0.5, 0, 0]))
18 | .setType('VEC4');
19 | const primDef = document.createPrimitive()
20 | .setAttribute('POSITION', positionDef)
21 | .setAttribute('JOINTS_0', jointsDef)
22 | .setAttribute('WEIGHTS_0', weightsDef);
23 | const meshDef = document.createMesh('Mesh').addPrimitive(primDef);
24 | const jointBDef = document.createNode('JointB');
25 | const jointADef = document.createNode('JointA').addChild(jointBDef);
26 | const skin = document.createSkin()
27 | .addJoint(jointADef)
28 | .addJoint(jointBDef);
29 | const armatureDef = document.createNode('Armature')
30 | .addChild(jointADef)
31 | .setSkin(skin)
32 | .setMesh(meshDef);
33 |
34 | const documentView = new DocumentView(document, {imageProvider});
35 | const armature = documentView.view(armatureDef);
36 | const boneA = armature.children.find((child) => child.name === 'JointA') as Bone;
37 | const boneB = boneA.children[0];
38 | const mesh = armature.children.find((child) => child.name === 'Mesh') as Mesh;
39 | const prim = mesh.children.find((child) => child.type === 'SkinnedMesh') as SkinnedMesh;
40 |
41 | t.is(armature.name, 'Armature', 'armature → name');
42 | t.is(mesh.type, 'Group', 'armature → mesh');
43 | t.is(prim.type, 'SkinnedMesh', 'armature → mesh → prim');
44 | t.is(boneA.type, 'Bone', 'armature → jointA');
45 | t.is(boneB.type, 'Bone', 'armature → jointA → jointB');
46 | t.truthy(prim.skeleton, 'skeleton');
47 | t.deepEqual(prim.skeleton.bones, [boneA, boneB], 'skeleton.bones');
48 | t.is(prim.skeleton.boneInverses.length, 2, 'skeleton.boneInverses');
49 | });
50 |
--------------------------------------------------------------------------------
/src/pools/Pool.ts:
--------------------------------------------------------------------------------
1 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
2 |
3 | export type EmptyParams = object | null | undefined;
4 |
5 | export interface ValuePool {
6 | requestBase(base: Value): Value;
7 | releaseBase(base: Value): void;
8 |
9 | requestVariant(base: Value, params: Params): Value;
10 | releaseVariant(variant: Value): void;
11 |
12 | gc(): void;
13 | size(): number;
14 | dispose(): void;
15 | }
16 |
17 | /**
18 | * Pool of (optionally reusable) resources and resource variations.
19 | *
20 | * As Subjects publish many variations of the same values to Observers, it's important to
21 | * allocate those variations efficiently, reuse instances where possible, and clean up unused
22 | * instances. That bookkeeping is assigned to Pools (not a Reactive concept).
23 | *
24 | * @internal
25 | */
26 | export class Pool implements ValuePool {
27 | readonly name: string;
28 | readonly documentView: DocumentViewSubjectAPI;
29 |
30 | protected _users = new Map();
31 |
32 | constructor(name: string, documentView: DocumentViewSubjectAPI) {
33 | this.name = name;
34 | this.documentView = documentView;
35 | }
36 |
37 | protected _request(value: Value): Value {
38 | let users = this._users.get(value) || 0;
39 | this._users.set(value, ++users);
40 | return value;
41 | }
42 |
43 | protected _release(value: Value): Value {
44 | let users = this._users.get(value) || 0;
45 | this._users.set(value, --users);
46 | return value;
47 | }
48 |
49 | protected _disposeValue(value: Value): void {
50 | this._users.delete(value);
51 | }
52 |
53 | requestBase(base: Value): Value {
54 | return this._request(base);
55 | }
56 |
57 | releaseBase(base: Value): void {
58 | this._release(base);
59 | }
60 |
61 | requestVariant(base: Value, _params: EmptyParams) {
62 | return this._request(base);
63 | }
64 |
65 | releaseVariant(variant: Value): void {
66 | this._release(variant);
67 | }
68 |
69 | gc(): void {
70 | for (const [value, users] of this._users) {
71 | if (users <= 0) this._disposeValue(value);
72 | }
73 | }
74 |
75 | size(): number {
76 | return this._users.size;
77 | }
78 |
79 | dispose(): void {
80 | for (const [value] of this._users) {
81 | this._disposeValue(value);
82 | }
83 | this._users.clear();
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/subjects/LightSubject.ts:
--------------------------------------------------------------------------------
1 | import { DirectionalLight, PointLight, SpotLight } from 'three';
2 | import { Light as LightDef } from '@gltf-transform/extensions';
3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
4 | import { Subject } from './Subject';
5 | import { ValuePool } from '../pools';
6 | import { LightLike } from '../constants';
7 |
8 | /** @internal */
9 | export class LightSubject extends Subject {
10 | constructor(documentView: DocumentViewSubjectAPI, def: LightDef) {
11 | super(documentView, def, LightSubject.createValue(def, documentView.lightPool), documentView.lightPool);
12 | }
13 |
14 | private static createValue(def: LightDef, pool: ValuePool): LightLike {
15 | switch (def.getType()) {
16 | case LightDef.Type.POINT:
17 | return pool.requestBase(new PointLight());
18 | case LightDef.Type.SPOT:
19 | return pool.requestBase(new SpotLight());
20 | case LightDef.Type.DIRECTIONAL:
21 | return pool.requestBase(new DirectionalLight());
22 | default:
23 | throw new Error(`Unexpected light type: ${def.getType()}`);
24 | }
25 | }
26 |
27 | update() {
28 | const def = this.def;
29 | let value = this.value;
30 |
31 | if (getType(def) !== value.type) {
32 | this.pool.releaseBase(value);
33 | this.value = value = LightSubject.createValue(def, this.pool);
34 | }
35 |
36 | value.name = def.getName();
37 | value.color.fromArray(def.getColor());
38 | value.intensity = def.getIntensity();
39 | value.position.set(0, 0, 0); // non-default for SpotLight
40 |
41 | if (value instanceof PointLight) {
42 | value.distance = def.getRange() || 0;
43 | value.decay = 2;
44 | } else if (value instanceof SpotLight) {
45 | value.distance = def.getRange() || 0;
46 | value.angle = def.getOuterConeAngle();
47 | value.penumbra = 1.0 - def.getInnerConeAngle() / def.getOuterConeAngle();
48 | value.decay = 2;
49 | value.target.position.set(0, 0, -1);
50 | value.add(value.target);
51 | } else if (value instanceof DirectionalLight) {
52 | value.target.position.set(0, 0, -1);
53 | value.add(value.target);
54 | }
55 | }
56 | }
57 |
58 | function getType(def: LightDef): string {
59 | switch (def.getType()) {
60 | case LightDef.Type.POINT:
61 | return 'PointLight';
62 | case LightDef.Type.SPOT:
63 | return 'SpotLight';
64 | case LightDef.Type.DIRECTIONAL:
65 | return 'DirectionalLight';
66 | default:
67 | throw new Error(`Unexpected light type: ${def.getType()}`);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/test/TextureSubject.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { MeshStandardMaterial, NoColorSpace, SRGBColorSpace, Texture } from 'three';
3 | import { Document } from '@gltf-transform/core';
4 | import { DocumentView, NullImageProvider } from '@gltf-transform/view';
5 |
6 | const imageProvider = new NullImageProvider();
7 |
8 | test('TextureBinding', async t => {
9 | const document = new Document();
10 | const textureDef = document.createTexture('MyTexture')
11 | .setImage(new Uint8Array(0))
12 | .setMimeType('image/png')
13 | .setExtras({textureExtras: true});
14 | const materialDef = document.createMaterial()
15 | .setBaseColorTexture(textureDef)
16 | .setMetallicRoughnessTexture(textureDef);
17 | materialDef.getMetallicRoughnessTextureInfo()!
18 | .setTexCoord(1);
19 |
20 | const documentView = new DocumentView(document, {imageProvider});
21 | const texture = documentView.view(textureDef);
22 | const material = documentView.view(materialDef) as MeshStandardMaterial;
23 | const map = material.map as Texture;
24 | const metalnessMap = material.metalnessMap as Texture;
25 | const roughnessMap = material.roughnessMap as Texture;
26 |
27 | t.truthy(texture, 'texture');
28 | t.is(map.colorSpace, SRGBColorSpace, 'map.colorSpace = "srgb"');
29 | t.is(map.channel, 0, 'map.channel = 0');
30 | t.is(roughnessMap.colorSpace, NoColorSpace, 'no color space');
31 | t.is(metalnessMap.colorSpace, NoColorSpace, 'no color space');
32 | t.is(roughnessMap.channel, 1, 'roughnessMap.channel = 1');
33 | t.is(metalnessMap.channel, 1, 'metalnessMap.channel = 1');
34 | t.true(map.source === metalnessMap.source, 'map.source === metalnessMap.source');
35 | t.true(metalnessMap === roughnessMap, 'metalnessMap === roughnessMap');
36 | t.falsy(texture.flipY || map.flipY || roughnessMap.flipY || metalnessMap.flipY, 'flipY=false');
37 |
38 | const disposed = new Set();
39 | texture.addEventListener('dispose', () => disposed.add(texture));
40 | map.addEventListener('dispose', () => disposed.add(map));
41 | metalnessMap.addEventListener('dispose', () => disposed.add(metalnessMap));
42 | roughnessMap.addEventListener('dispose', () => disposed.add(roughnessMap));
43 |
44 | materialDef.setBaseColorTexture(null);
45 | documentView.gc();
46 |
47 | t.is(disposed.size, 1, 'dispose count (1/3)');
48 | t.true(disposed.has(map), 'dispose map');
49 |
50 | materialDef.dispose();
51 | documentView.gc();
52 |
53 | t.is(disposed.size, 2, 'dispose count (2/3)');
54 | t.true(disposed.has(map), 'dispose roughnessMap, metalnessMap');
55 |
56 | textureDef.dispose();
57 | documentView.gc();
58 |
59 | t.is(disposed.size, 3, 'dispose count (3/3)');
60 | t.true(disposed.has(texture), 'dispose roughnessMap, metalnessMap');
61 | });
62 |
--------------------------------------------------------------------------------
/src/subjects/SkinSubject.ts:
--------------------------------------------------------------------------------
1 | import { Bone, BufferAttribute, Matrix4, Skeleton } from 'three';
2 | import { Accessor as AccessorDef, Node as NodeDef, Skin as SkinDef } from '@gltf-transform/core';
3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
4 | import { Subject } from './Subject';
5 | import { RefListObserver, RefObserver } from '../observers';
6 | import { ValuePool } from '../pools';
7 |
8 | /**
9 | * SkinSubject transforms `nodeDef.skin` into a THREE.Skeleton instance. The upstream
10 | * {@link NodeSubject} will bind the skeleton to the mesh, where {@link PrimitiveSubject}
11 | * is responsible for emitting a THREE.SkinnedMesh if it contains skinning-related attributes.
12 | *
13 | * This subject does not guard against certain invalid states — missing bones, missing
14 | * vertex weights — and should be used accordingly.
15 | *
16 | * @internal
17 | */
18 | export class SkinSubject extends Subject {
19 | protected joints = new RefListObserver('children', this._documentView);
20 | protected inverseBindMatrices = new RefObserver(
21 | 'inverseBindMatrices',
22 | this._documentView,
23 | );
24 |
25 | /** Output (Skeleton) is never cloned by an observer. */
26 | protected _outputSingleton = true;
27 |
28 | constructor(documentView: DocumentViewSubjectAPI, def: SkinDef) {
29 | super(documentView, def, SkinSubject.createValue(def, [], null, documentView.skinPool), documentView.skinPool);
30 |
31 | this.joints.subscribe((joints) => {
32 | this.pool.releaseBase(this.value);
33 | this.value = SkinSubject.createValue(def, joints, this.inverseBindMatrices.value, this.pool);
34 | this.publishAll();
35 | });
36 | this.inverseBindMatrices.subscribe((inverseBindMatrices) => {
37 | this.pool.releaseBase(this.value);
38 | this.value = SkinSubject.createValue(def, this.joints.value, inverseBindMatrices, this.pool);
39 | this.publishAll();
40 | });
41 | }
42 |
43 | private static createValue(
44 | _def: SkinDef,
45 | bones: Bone[],
46 | ibm: BufferAttribute | null,
47 | pool: ValuePool,
48 | ): Skeleton {
49 | const boneInverses: Matrix4[] = [];
50 |
51 | for (let i = 0; i < bones.length; i++) {
52 | const matrix = new Matrix4();
53 | if (ibm) matrix.fromArray(ibm.array, i * 16);
54 | boneInverses.push(matrix);
55 | }
56 |
57 | return pool.requestBase(new Skeleton(bones, boneInverses));
58 | }
59 |
60 | update() {
61 | this.joints.update(this.def.listJoints());
62 | this.inverseBindMatrices.update(this.def.getInverseBindMatrices());
63 | }
64 |
65 | dispose() {
66 | this.joints.dispose();
67 | this.inverseBindMatrices.dispose();
68 | super.dispose();
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/CONTRIBUTOR_LICENSE_AGREEMENT.md:
--------------------------------------------------------------------------------
1 | # Blue Oak Contributor License
2 |
3 | Version 1.0.0
4 |
5 | ## Purpose
6 |
7 | This license gives the steward of a software project
8 | the legal rights and assurances it needs to accept
9 | contributions covered by intellectual property rights.
10 |
11 | ## Copyright
12 |
13 | The contributor licenses the steward to do everything with
14 | their contributions that would otherwise infringe their
15 | copyrights in them.
16 |
17 | ## Patent
18 |
19 | The contributor licenses the steward to do everything with
20 | their contributions that would otherwise infringe any patent
21 | claims they can license or become able to license.
22 |
23 | ## Sublicensing
24 |
25 | The steward can license the contributions to others in
26 | turn, under whatever terms it chooses.
27 |
28 | ## Reliability
29 |
30 | The contributor cannot revoke this license.
31 |
32 | ## Termination
33 |
34 | The contributor can stop this license from covering
35 | contributions submitted in the future by writing the steward
36 | through one of the channels it uses to receive contributions.
37 | Contributions the contributor submitted before remain under
38 | this license.
39 |
40 | ## Awareness
41 |
42 | The contributor is aware that because of legal rules and
43 | contracts, they may need to get someone else's permission to
44 | submit contributions, even of their own work. For example,
45 | "work made for hire" rules can make employers and clients
46 | the owners of copyrights in the contributor's work.
47 | Contracts with clients, or to form new companies, may make
48 | them the owners of copyrights in the contributor's work.
49 |
50 | ## Rights
51 |
52 | All work in contributions the contributor submits for
53 | inclusion in the project will be either:
54 |
55 | - their own work, which they own the copyrights in or
56 | have permission to submit
57 |
58 | - work licensed by others under permissive license
59 | terms that Blue Oak Council rates "bronze" or better at
60 |
61 |
62 | ## Notices
63 |
64 | The contributor promises to copy copyright notices,
65 | license terms, and other licensing-related notices for
66 | work licensed by others in their contributions, so the
67 | steward can tell what work is licensed by others under
68 | what terms and follow their notice requirements.
69 |
70 | ## No Liability
71 |
72 | ***As far as the law allows, contributions under this
73 | license come as is, without any warranty or condition,
74 | except those under "Rights" above.***
75 |
76 | ***As far as the law allows, the contributor will not be
77 | liable to anyone for any damages related to contributions
78 | under this license, under any kind of legal claim, except
79 | for claims under "Rights" above.***
80 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@gltf-transform/view",
3 | "version": "0.15.0",
4 | "repository": "github:donmccurdy/glTF-Transform-View",
5 | "description": "Syncs a glTF-Transform Document with a three.js scene graph",
6 | "author": "Don McCurdy ",
7 | "license": "BlueOak-1.0.0",
8 | "type": "module",
9 | "sideEffects": false,
10 | "source": "./src/index.ts",
11 | "types": "./dist/index.d.ts",
12 | "module": "./dist/view.modern.js",
13 | "exports": {
14 | "types": "./dist/index.d.ts",
15 | "default": "./dist/view.modern.js"
16 | },
17 | "peerDependencies": {
18 | "@gltf-transform/core": ">=3.3.0",
19 | "@gltf-transform/extensions": ">=3.3.0",
20 | "@types/three": ">=0.152.0",
21 | "three": ">=0.152.0"
22 | },
23 | "devDependencies": {
24 | "@gltf-transform/core": "3.10.1",
25 | "@gltf-transform/extensions": "3.10.1",
26 | "@gltf-transform/functions": "3.10.1",
27 | "@tweakpane/core": "2.0.3",
28 | "@types/node": "^20.11.16",
29 | "@types/three": "0.164.0",
30 | "@typescript-eslint/eslint-plugin": "7.8.0",
31 | "@typescript-eslint/parser": "7.8.0",
32 | "ava": "^6.1.1",
33 | "c8": "^9.1.0",
34 | "concurrently": "8.2.2",
35 | "eslint": "^9.0.0",
36 | "eslint-config-prettier": "^9.1.0",
37 | "jsdom": "^22.0.0",
38 | "jsdom-global": "^3.0.2",
39 | "meshoptimizer": "^0.20.0",
40 | "microbundle": "0.15.1",
41 | "rimraf": "^5.0.5",
42 | "simple-dropzone": "0.8.3",
43 | "three": "0.164.1",
44 | "tsx": "^4.7.3",
45 | "tweakpane": "^3.0.0",
46 | "tweakpane-plugin-thumbnail-list": "0.3.0",
47 | "typescript": "5.4.5",
48 | "typescript-eslint": "^7.8.0",
49 | "vite": "5.2.10"
50 | },
51 | "scripts": {
52 | "dist": "microbundle --no-compress --format modern",
53 | "watch": "microbundle watch --no-compress --format modern",
54 | "dev": "concurrently \"yarn watch\" \"vite -c examples/vite.config.js\"",
55 | "clean": "rimraf dist/**",
56 | "test": "ava --no-worker-threads test/**/*.test.ts",
57 | "lint": "eslint \"{src,test}/**/*.ts\"",
58 | "coverage": "c8 --reporter=lcov --reporter=text yarn test --tap",
59 | "coverage:report": "c8 report --reporter=text-lcov > coverage/coverage.lcov",
60 | "version": "yarn dist && yarn test",
61 | "postversion": "git push && git push --tags && npm publish"
62 | },
63 | "browserslist": [
64 | "last 2 and_chr versions",
65 | "last 2 chrome versions",
66 | "last 2 opera versions",
67 | "last 2 ios_saf versions",
68 | "last 2 safari versions",
69 | "last 2 firefox versions"
70 | ],
71 | "files": [
72 | "dist/",
73 | "src/",
74 | "README.md",
75 | "LICENSE.md"
76 | ],
77 | "ava": {
78 | "extensions": {
79 | "ts": "module"
80 | },
81 | "nodeArguments": [
82 | "--import=tsx",
83 | "--require=jsdom-global/register"
84 | ]
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/subjects/InstancedMeshSubject.ts:
--------------------------------------------------------------------------------
1 | import { BufferAttribute, BufferGeometry, InstancedMesh, Matrix4, Quaternion, Vector3 } from 'three';
2 | import { Accessor as AccessorDef } from '@gltf-transform/core';
3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
4 | import { Subject } from './Subject';
5 | import { RefMapObserver } from '../observers';
6 | import { ValuePool } from '../pools';
7 | import { InstancedMesh as InstancedMeshDef } from '@gltf-transform/extensions';
8 | import { DEFAULT_MATERIAL, semanticToAttributeName } from '../utils';
9 |
10 | const _t = new Vector3();
11 | const _r = new Quaternion();
12 | const _s = new Vector3();
13 | const _matrix = new Matrix4();
14 |
15 | /** @internal */
16 | export class InstancedMeshSubject extends Subject {
17 | protected attributes = new RefMapObserver('attributes', this._documentView);
18 |
19 | constructor(documentView: DocumentViewSubjectAPI, def: InstancedMeshDef) {
20 | super(
21 | documentView,
22 | def,
23 | InstancedMeshSubject.createValue(getCount({}), documentView.instancedMeshPool),
24 | documentView.instancedMeshPool,
25 | );
26 |
27 | this.attributes.subscribe((nextAttributes) => {
28 | let value = this.value;
29 | if (value) this.pool.releaseBase(value);
30 |
31 | value = InstancedMeshSubject.createValue(getCount(nextAttributes), documentView.instancedMeshPool);
32 |
33 | let translation: BufferAttribute | null = null;
34 | let rotation: BufferAttribute | null = null;
35 | let scale: BufferAttribute | null = null;
36 |
37 | for (const key in nextAttributes) {
38 | if (key === 'TRANSLATION') {
39 | translation = nextAttributes[key];
40 | } else if (key === 'ROTATION') {
41 | rotation = nextAttributes[key];
42 | } else if (key === 'SCALE') {
43 | scale = nextAttributes[key];
44 | } else {
45 | value.geometry.setAttribute(semanticToAttributeName(key), nextAttributes[key]);
46 | }
47 | }
48 |
49 | _t.set(0, 0, 0);
50 | _r.set(0, 0, 0, 1);
51 | _s.set(1, 1, 1);
52 |
53 | for (let i = 0; i < value.count; i++) {
54 | if (translation) _t.fromBufferAttribute(translation, i);
55 | if (rotation) _r.fromBufferAttribute(rotation, i);
56 | if (scale) _s.fromBufferAttribute(scale, i);
57 | _matrix.compose(_t, _r, _s);
58 | value.setMatrixAt(i, _matrix);
59 | }
60 |
61 | this.value = value;
62 |
63 | this.publishAll();
64 | });
65 | }
66 |
67 | update() {
68 | const def = this.def;
69 |
70 | this.attributes.update(def.listSemantics(), def.listAttributes());
71 | }
72 |
73 | private static createValue(count: number, pool: ValuePool): InstancedMesh {
74 | return pool.requestBase(new InstancedMesh(new BufferGeometry(), DEFAULT_MATERIAL, count));
75 | }
76 |
77 | dispose() {
78 | this.value.geometry.dispose();
79 | this.attributes.dispose();
80 | super.dispose();
81 | }
82 | }
83 |
84 | function getCount(attributes: Record): number {
85 | for (const key in attributes) {
86 | return attributes[key].count;
87 | }
88 | return 1;
89 | }
90 |
--------------------------------------------------------------------------------
/src/observers/RefMapObserver.ts:
--------------------------------------------------------------------------------
1 | import type { Property as PropertyDef } from '@gltf-transform/core';
2 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
3 | import type { Subject } from '../subjects';
4 | import type { Subscription } from '../constants';
5 | import { Observable } from '../utils';
6 | import { EmptyParams } from '../pools';
7 | import { RefObserver } from './RefObserver';
8 |
9 | /** @internal */
10 | export class RefMapObserver<
11 | Def extends PropertyDef,
12 | Value,
13 | Params extends EmptyParams = EmptyParams,
14 | > extends Observable> {
15 | readonly name: string;
16 |
17 | protected readonly _documentView: DocumentViewSubjectAPI;
18 |
19 | private readonly _observers: Record> = {};
20 | private readonly _subscriptions: Record = {};
21 |
22 | constructor(name: string, documentView: DocumentViewSubjectAPI) {
23 | super({});
24 | this.name = name;
25 | this._documentView = documentView;
26 | }
27 |
28 | update(keys: string[], defs: Def[]) {
29 | const nextKeys = new Set(keys);
30 | const nextDefs = {} as Record;
31 | for (let i = 0; i < keys.length; i++) nextDefs[keys[i]] = defs[i];
32 |
33 | let needsUpdate = false;
34 |
35 | for (const key in this._observers) {
36 | if (!nextKeys.has(key)) {
37 | this._remove(key);
38 | needsUpdate = true;
39 | }
40 | }
41 |
42 | for (const key of keys) {
43 | const observer = this._observers[key];
44 | if (!observer) {
45 | this._add(key, this._documentView.bind(nextDefs[key]) as Subject);
46 | needsUpdate = true;
47 | } else if (observer.getDef() !== nextDefs[key]) {
48 | observer.update(nextDefs[key]);
49 | needsUpdate = true;
50 | }
51 | }
52 |
53 | if (needsUpdate) {
54 | this._publish();
55 | }
56 | }
57 |
58 | setParamsFn(paramsFn: () => Params): this {
59 | for (const key in this._observers) {
60 | const observer = this._observers[key];
61 | observer.setParamsFn(paramsFn);
62 | }
63 | return this;
64 | }
65 |
66 | private _add(key: string, subject: Subject) {
67 | const observer = new RefObserver(this.name + `[${key}]`, this._documentView) as RefObserver;
68 | observer.update(subject.def);
69 |
70 | this._observers[key] = observer;
71 | this._subscriptions[key] = observer.subscribe((next) => {
72 | if (!next) {
73 | this._remove(key);
74 | }
75 | this._publish();
76 | });
77 | }
78 |
79 | private _remove(key: string) {
80 | const observer = this._observers[key];
81 | const unsub = this._subscriptions[key];
82 |
83 | unsub();
84 | observer.dispose();
85 |
86 | delete this._subscriptions[key];
87 | delete this._observers[key];
88 | }
89 |
90 | private _publish() {
91 | const entries = Object.entries(this._observers).map(([key, observer]) => [key, observer.value]);
92 | this.next(Object.fromEntries(entries));
93 | }
94 |
95 | dispose() {
96 | for (const key in this._observers) {
97 | const observer = this._observers[key];
98 | const unsub = this._subscriptions[key];
99 | unsub();
100 | observer.dispose();
101 | delete this._subscriptions[key];
102 | delete this._observers[key];
103 | }
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/observers/RefListObserver.ts:
--------------------------------------------------------------------------------
1 | import type { Property as PropertyDef } from '@gltf-transform/core';
2 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
3 | import type { Subject } from '../subjects';
4 | import type { Subscription } from '../constants';
5 | import { Observable } from '../utils';
6 | import { EmptyParams } from '../pools';
7 | import { RefObserver } from './RefObserver';
8 |
9 | /** @internal */
10 | export class RefListObserver<
11 | Def extends PropertyDef,
12 | Value,
13 | Params extends EmptyParams = EmptyParams,
14 | > extends Observable {
15 | readonly name: string;
16 |
17 | protected readonly _documentView: DocumentViewSubjectAPI;
18 |
19 | private readonly _observers: RefObserver[] = [];
20 | private readonly _subscriptions: Subscription[] = [];
21 |
22 | constructor(name: string, documentView: DocumentViewSubjectAPI) {
23 | super([]);
24 | this.name = name;
25 | this._documentView = documentView;
26 | }
27 |
28 | update(defs: Def[]) {
29 | const added = new Set>();
30 | const removed = new Set();
31 |
32 | let needsUpdate = false;
33 |
34 | // TODO(perf): Is this an many next()s as it looks like? Maybe
35 | // only when an early index is removed from a longer list?
36 | for (let i = 0; i < defs.length || i < this._observers.length; i++) {
37 | const def = defs[i];
38 | const observer = this._observers[i];
39 |
40 | if (!def) {
41 | removed.add(i);
42 | needsUpdate = true;
43 | } else if (!observer) {
44 | added.add(this._documentView.bind(def) as Subject);
45 | needsUpdate = true;
46 | } else if (def !== observer.getDef()) {
47 | observer.update(def);
48 | needsUpdate = true;
49 | }
50 | }
51 |
52 | for (let i = this._observers.length; i >= 0; i--) {
53 | if (removed.has(i)) {
54 | this._remove(i);
55 | }
56 | }
57 |
58 | for (const add of added) {
59 | this._add(add);
60 | }
61 |
62 | if (needsUpdate) {
63 | this._publish();
64 | }
65 | }
66 |
67 | setParamsFn(paramsFn: () => Params): this {
68 | for (const observer of this._observers) {
69 | observer.setParamsFn(paramsFn);
70 | }
71 | return this;
72 | }
73 |
74 | private _add(subject: Subject) {
75 | const observer = new RefObserver(this.name + '[]', this._documentView) as RefObserver;
76 | observer.update(subject.def);
77 | this._observers.push(observer);
78 | this._subscriptions.push(
79 | observer.subscribe((next) => {
80 | if (!next) {
81 | this._remove(this._observers.indexOf(observer));
82 | }
83 | this._publish();
84 | }),
85 | );
86 | }
87 |
88 | private _remove(index: number) {
89 | const observer = this._observers[index];
90 | const unsub = this._subscriptions[index];
91 |
92 | unsub();
93 | observer.dispose();
94 |
95 | this._observers.splice(index, 1);
96 | this._subscriptions.splice(index, 1);
97 | }
98 |
99 | private _publish() {
100 | this.next(this._observers.map((o) => o.value!));
101 | }
102 |
103 | dispose() {
104 | for (const unsub of this._subscriptions) unsub();
105 | for (const observer of this._observers) observer.dispose();
106 | this._subscriptions.length = 0;
107 | this._observers.length = 0;
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/test/NodeSubject.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { Document } from '@gltf-transform/core';
3 | import { DocumentView, NullImageProvider } from '@gltf-transform/view';
4 |
5 | const imageProvider = new NullImageProvider();
6 |
7 | test('NodeSubject', async t => {
8 | const document = new Document();
9 | const nodeDef1 = document.createNode('Node1')
10 | .setTranslation([0, 2, 0])
11 | .setRotation([0, 0, .707, .707])
12 | .setScale([0.5, 0.5, 0.5])
13 | .addChild(document.createNode('Node2').setTranslation([5, 0, 0]));
14 |
15 | const documentView = new DocumentView(document, {imageProvider});
16 | const node1 = documentView.view(nodeDef1);
17 |
18 | t.is(node1.name, 'Node1', 'node1 → name');
19 | t.is(node1.children.length, 1, 'node1 → children');
20 | t.deepEqual(node1.position.toArray(), [0, 2, 0], 'node1 → position');
21 | t.deepEqual(node1.quaternion.toArray(), [0, 0, .707, .707], 'node1 → quaternion');
22 | t.deepEqual(node1.scale.toArray(), [0.5, 0.5, 0.5], 'node1 → scale');
23 |
24 | const node2 = node1.children[0];
25 | t.is(node2.name, 'Node2', 'node2 → name');
26 | t.is(node2.children.length, 0, 'node2 → children');
27 | t.deepEqual(node2.position.toArray(), [5, 0, 0], 'node2 → position');
28 | t.deepEqual(node2.quaternion.toArray(), [0, 0, 0, 1], 'node2 → quaternion');
29 | t.deepEqual(node2.scale.toArray(), [1, 1, 1], 'node2 → scale');
30 |
31 | nodeDef1
32 | .setName('RenamedNode')
33 | .setTranslation([0, 0, 0,]);
34 |
35 | t.is(node1.name, 'RenamedNode', 'node1 → name');
36 | t.deepEqual(node1.position.toArray(), [0, 0, 0], 'node1 → position');
37 | });
38 |
39 | test('NodeSubject | update in place', async t => {
40 | const document = new Document();
41 | const meshDef = document.createMesh().setName('Mesh.v1');
42 | const nodeDef1 = document.createNode('Node1').setMesh(meshDef);
43 | const nodeDef2 = document.createNode('Node2').setMesh(meshDef).addChild(nodeDef1);
44 | const sceneDef = document.createScene().addChild(nodeDef2);
45 |
46 | const documentView = new DocumentView(document, {imageProvider});
47 | const scene = documentView.view(sceneDef);
48 | const node1 = documentView.view(nodeDef1);
49 | const node2 = documentView.view(nodeDef2);
50 | const mesh = node1.children[0];
51 |
52 | t.truthy(scene, 'scene ok');
53 | t.truthy(node1, 'node1 ok');
54 | t.truthy(node2, 'node2 ok');
55 | t.truthy(mesh, 'mesh ok');
56 |
57 | t.is(scene.children[0], node2, 'node2 view');
58 | t.is(scene.children[0].children[0], node1, 'node1 view');
59 | t.is(scene.children[0].children[0].children[0], mesh, 'mesh view');
60 |
61 | nodeDef1.setScale([2, 2, 2]);
62 | nodeDef2.setScale([3, 3, 3]);
63 |
64 | t.is(scene.children[0], node2, 'node2 view after update');
65 | t.is(scene.children[0].children[0], node1, 'node1 view after update');
66 | t.is(scene.children[0].children[0].children[0], mesh, 'mesh view');
67 |
68 | t.deepEqual(node1.scale.toArray([]), [2, 2, 2], 'node1 scale');
69 | t.deepEqual(node2.scale.toArray([]), [3, 3, 3], 'node2 scale');
70 |
71 | t.truthy(node1.children.some((o) => o.name === 'Mesh.v1'), 'node1.mesh.name');
72 | t.truthy(node2.children.some((o) => o.name === 'Mesh.v1'), 'node2.mesh.name');
73 | meshDef.setName('Mesh.v2');
74 | t.truthy(node1.children.some((o) => o.name === 'Mesh.v2'), 'node1.mesh.name');
75 | t.truthy(node2.children.some((o) => o.name === 'Mesh.v2'), 'node2.mesh.name');
76 | });
77 |
--------------------------------------------------------------------------------
/examples/dropzone.ts:
--------------------------------------------------------------------------------
1 | import { Document, JSONDocument } from '@gltf-transform/core';
2 | import { SimpleDropzone } from 'simple-dropzone';
3 | import { createIO } from './util';
4 |
5 | /**
6 | * Dropzone
7 | *
8 | * Utility file to load glTF/GLB models and emit a Document.
9 | */
10 |
11 | const dropEl = document.querySelector('body')!;
12 | const placeholderEl = document.querySelector('.dropzone-placeholder')!;
13 | const inputEl = document.querySelector('#file-input')!;
14 |
15 | document.addEventListener('DOMContentLoaded', () => {
16 | const dropzone = new SimpleDropzone(dropEl, inputEl) as any;
17 | dropzone.on('drop', async ({files}) => {
18 | try {
19 | await load(files);
20 | placeholderEl.style.display = 'none';
21 | } catch (e) {
22 | alert(e.message);
23 | }
24 | });
25 | });
26 |
27 | async function load (fileMap: Map) {
28 | let rootFile: File | null = null;
29 | let rootPath = '';
30 | let images: File[] = [];
31 | Array.from(fileMap).forEach(([path, file]) => {
32 | if (file.name.match(/\.(gltf|glb)$/)) {
33 | rootFile = file;
34 | rootPath = path.replace(file.name, '');
35 | } else if (file.name.match(/\.(png|jpg|jpeg|ktx2|webp)$/)) {
36 | images.push(file);
37 | }
38 | });
39 |
40 | if (rootFile) return loadDocument(fileMap, rootFile, rootPath);
41 | throw new Error('No .gltf, .glb, or texture asset found.');
42 | }
43 |
44 | async function loadDocument(fileMap: Map, rootFile: File, rootPath: string) {
45 | const io = createIO();
46 | let jsonDocument: JSONDocument;
47 | let doc: Document;
48 |
49 | if (rootFile.name.match(/\.(glb)$/)) {
50 | const arrayBuffer = await rootFile.arrayBuffer();
51 | jsonDocument = await io.binaryToJSON(new Uint8Array(arrayBuffer));
52 | } else {
53 | jsonDocument = {
54 | json: JSON.parse(await rootFile.text()),
55 | resources: {},
56 | };
57 | for (const [fileName, file] of fileMap.entries()) {
58 | const path = fileName.replace(rootPath, '');
59 | const arrayBuffer = await file.arrayBuffer();
60 | jsonDocument.resources[path] = new Uint8Array(arrayBuffer);
61 | }
62 | }
63 |
64 | normalizeURIs(jsonDocument);
65 | doc = await io.readJSON(jsonDocument);
66 | removeCompression(doc);
67 | document.body.dispatchEvent(new CustomEvent('gltf-document', {detail: doc}));
68 | }
69 |
70 | /**
71 | * Normalize URIs to match expected output from simple-dropzone, for folders or
72 | * ZIP archives. URIs in a glTF file may be escaped, or not. Assume that assetMap is
73 | * from an un-escaped source, and decode all URIs before lookups.
74 | * See: https://github.com/donmccurdy/three-gltf-viewer/issues/146
75 | */
76 | function normalizeURIs (jsonDocument: JSONDocument) {
77 | const images = jsonDocument.json.images || [];
78 | const buffers = jsonDocument.json.buffers || [];
79 | for (const resource of [...images, ...buffers]) {
80 | if (!resource.uri) continue;
81 | if (resource.uri.startsWith('data:')) continue;
82 |
83 | resource.uri = decodeURI(resource.uri).replace(/^(\.?\/)/, '');
84 | if (!(resource.uri in jsonDocument.resources)) {
85 | throw new Error(`Missing resource: ${resource.uri}`);
86 | }
87 | }
88 | }
89 |
90 | /** Remove compression extensions now so further writes don't recompress. */
91 | function removeCompression(document: Document) {
92 | for (const extensionName of ['KHR_draco_mesh_compression', 'EXT_meshopt_compression']) {
93 | const extension = document.getRoot().listExtensionsUsed()
94 | .find((extension) => extension.extensionName === extensionName);
95 | if (extension) extension.dispose();
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/test/LightSubject.test.ts:
--------------------------------------------------------------------------------
1 | import test from 'ava';
2 | import { Document } from '@gltf-transform/core';
3 | import { Light as LightDef, KHRLightsPunctual } from '@gltf-transform/extensions';
4 | import { DocumentView, NullImageProvider } from '@gltf-transform/view';
5 | import { DirectionalLight, PointLight, SpotLight } from 'three';
6 |
7 | const imageProvider = new NullImageProvider();
8 |
9 | test('LightSubject | point', async t => {
10 | const document = new Document();
11 | const lightExt = document.createExtension(KHRLightsPunctual);
12 | const lightDef = lightExt.createLight('MyLight')
13 | .setColor([1, 0, 0])
14 | .setIntensity(2000)
15 | .setRange(100)
16 | .setType(LightDef.Type.POINT);
17 | const nodeDef = document.createNode('Node')
18 | .setExtension('KHR_lights_punctual', lightDef);
19 |
20 | const documentView = new DocumentView(document, {imageProvider});
21 | const node = documentView.view(nodeDef);
22 | const light = node.children[0] as PointLight;
23 |
24 | t.is(light.name, 'MyLight', 'node → light → name');
25 | t.is(light.type, 'PointLight', 'node → light → type');
26 | t.deepEqual(light.position.toArray(), [0, 0, 0], 'node → light → position');
27 | t.is(light.intensity, 2000, 'node → light → intensity');
28 | t.is(light.distance, 100, 'node → light → range');
29 | t.deepEqual(light.color.toArray(), [1, 0, 0], 'node → light → color');
30 | t.is(light.decay, 2, 'node → light → decay');
31 | });
32 |
33 | test('LightSubject | spot', async t => {
34 | const document = new Document();
35 | const lightExt = document.createExtension(KHRLightsPunctual);
36 | const lightDef = lightExt.createLight('MyLight')
37 | .setColor([1, 1, 0])
38 | .setIntensity(2000)
39 | .setRange(null)
40 | .setInnerConeAngle(Math.PI / 4)
41 | .setOuterConeAngle(Math.PI / 2)
42 | .setType(LightDef.Type.SPOT);
43 | const nodeDef = document.createNode('Node')
44 | .setExtension('KHR_lights_punctual', lightDef);
45 |
46 | const documentView = new DocumentView(document, {imageProvider});
47 | const node = documentView.view(nodeDef);
48 | const light = node.children[0] as SpotLight;
49 |
50 | t.is(light.name, 'MyLight', 'node → light → name');
51 | t.is(light.type, 'SpotLight', 'node → light → type');
52 | t.deepEqual(light.position.toArray(), [0, 0, 0], 'node → light → position');
53 | t.is(light.intensity, 2000, 'node → light → intensity');
54 | t.is(light.distance, 0, 'node → light → range');
55 | t.is(light.angle, Math.PI / 2, 'node → light → angle');
56 | t.is(light.penumbra, 1.0 - (Math.PI / 4) / (Math.PI / 2), 'node → light → penumbra');
57 | t.deepEqual(light.color.toArray(), [1, 1, 0], 'node → light → color');
58 | t.is(light.decay, 2, 'node → light → decay');
59 | });
60 |
61 | test('LightSubject | directional', async t => {
62 | const document = new Document();
63 | const lightExt = document.createExtension(KHRLightsPunctual);
64 | const lightDef = lightExt.createLight('MyLight')
65 | .setColor([1, 1, 1])
66 | .setIntensity(1.5)
67 | .setType(LightDef.Type.DIRECTIONAL);
68 | const nodeDef = document.createNode('Node')
69 | .setExtension('KHR_lights_punctual', lightDef);
70 |
71 | const documentView = new DocumentView(document, {imageProvider});
72 | const node = documentView.view(nodeDef);
73 | const light = node.children[0] as DirectionalLight;
74 |
75 | t.is(light.name, 'MyLight', 'node → light → name');
76 | t.is(light.type, 'DirectionalLight', 'node → light → type');
77 | t.deepEqual(light.position.toArray(), [0, 0, 0], 'node → light → position');
78 | t.is(light.intensity, 1.5, 'node → light → intensity');
79 | t.deepEqual(light.color.toArray(), [1, 1, 1], 'node → light → color');
80 | });
81 |
--------------------------------------------------------------------------------
/src/pools/TexturePool.ts:
--------------------------------------------------------------------------------
1 | import { TextureInfo, vec2 } from '@gltf-transform/core';
2 | import {
3 | ClampToEdgeWrapping,
4 | ColorSpace,
5 | LinearFilter,
6 | LinearMipmapLinearFilter,
7 | LinearMipmapNearestFilter,
8 | MagnificationTextureFilter,
9 | MinificationTextureFilter,
10 | MirroredRepeatWrapping,
11 | NearestFilter,
12 | NearestMipmapLinearFilter,
13 | NearestMipmapNearestFilter,
14 | RepeatWrapping,
15 | Texture,
16 | TextureFilter,
17 | Wrapping,
18 | } from 'three';
19 | import type { Transform } from '@gltf-transform/extensions';
20 | import { Pool } from './Pool';
21 |
22 | const WEBGL_FILTERS: Record = {
23 | 9728: NearestFilter,
24 | 9729: LinearFilter,
25 | 9984: NearestMipmapNearestFilter,
26 | 9985: LinearMipmapNearestFilter,
27 | 9986: NearestMipmapLinearFilter,
28 | 9987: LinearMipmapLinearFilter,
29 | };
30 |
31 | const WEBGL_WRAPPINGS: Record = {
32 | 33071: ClampToEdgeWrapping,
33 | 33648: MirroredRepeatWrapping,
34 | 10497: RepeatWrapping,
35 | };
36 |
37 | export interface TextureParams {
38 | colorSpace: ColorSpace;
39 | channel: number;
40 | minFilter: TextureFilter;
41 | magFilter: TextureFilter;
42 | wrapS: Wrapping;
43 | wrapT: Wrapping;
44 | offset: vec2;
45 | rotation: number;
46 | repeat: vec2;
47 | }
48 |
49 | const _VEC2 = { ZERO: [0, 0] as vec2, ONE: [1, 1] as vec2 };
50 |
51 | /** @internal */
52 | export class TexturePool extends Pool {
53 | static createParams(textureInfo: TextureInfo, colorSpace: ColorSpace): TextureParams {
54 | const transform = textureInfo.getExtension('KHR_texture_transform');
55 | return {
56 | colorSpace: colorSpace,
57 | channel: textureInfo.getTexCoord(),
58 | minFilter: WEBGL_FILTERS[textureInfo.getMinFilter() as number] || LinearMipmapLinearFilter,
59 | magFilter: WEBGL_FILTERS[textureInfo.getMagFilter() as number] || LinearFilter,
60 | wrapS: WEBGL_WRAPPINGS[textureInfo.getWrapS()] || RepeatWrapping,
61 | wrapT: WEBGL_WRAPPINGS[textureInfo.getWrapT()] || RepeatWrapping,
62 | offset: transform?.getOffset() || _VEC2.ZERO,
63 | rotation: transform?.getRotation() || 0,
64 | repeat: transform?.getScale() || _VEC2.ONE,
65 | };
66 | }
67 |
68 | requestVariant(base: Texture, params: TextureParams): Texture {
69 | return this._request(this._createVariant(base, params));
70 | }
71 |
72 | protected _disposeValue(value: Texture): void {
73 | value.dispose();
74 | super._disposeValue(value);
75 | }
76 |
77 | protected _createVariant(srcTexture: Texture, params: TextureParams): Texture {
78 | return this._updateVariant(srcTexture, srcTexture.clone(), params);
79 | }
80 |
81 | protected _updateVariant(srcTexture: Texture, dstTexture: Texture, params: TextureParams): Texture {
82 | const needsUpdate =
83 | srcTexture.image !== dstTexture.image ||
84 | dstTexture.colorSpace !== params.colorSpace ||
85 | dstTexture.wrapS !== params.wrapS ||
86 | dstTexture.wrapT !== params.wrapT;
87 |
88 | dstTexture.copy(srcTexture);
89 | dstTexture.colorSpace = params.colorSpace;
90 | dstTexture.channel = params.channel;
91 | dstTexture.minFilter = params.minFilter as MinificationTextureFilter;
92 | dstTexture.magFilter = params.magFilter as MagnificationTextureFilter;
93 | dstTexture.wrapS = params.wrapS;
94 | dstTexture.wrapT = params.wrapT;
95 | dstTexture.offset.fromArray(params.offset || _VEC2.ZERO);
96 | dstTexture.rotation = params.rotation || 0;
97 | dstTexture.repeat.fromArray(params.repeat || _VEC2.ONE);
98 |
99 | if (needsUpdate) {
100 | dstTexture.needsUpdate = true;
101 | }
102 |
103 | return dstTexture;
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/examples/1-model.ts:
--------------------------------------------------------------------------------
1 | import { ACESFilmicToneMapping, AmbientLight, DirectionalLight, PMREMGenerator, PerspectiveCamera, Scene, WebGLRenderer } from 'three';
2 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
3 | import { GLTF, Material } from '@gltf-transform/core';
4 | import { DocumentView } from '../dist/view.modern.js';
5 | import {Pane} from 'tweakpane';
6 | import * as TweakpanePluginThumbnailList from 'tweakpane-plugin-thumbnail-list';
7 | import { createStatsPane } from './stats-pane.js';
8 | import { createMaterialPane } from './material-pane.js';
9 | import { createEnvironment, createIO } from './util.js';
10 |
11 | const renderer = new WebGLRenderer({antialias: true});
12 | renderer.setPixelRatio(window.devicePixelRatio);
13 | renderer.setSize(window.innerWidth, window.innerHeight);
14 | renderer.useLegacyLights = false;
15 | renderer.toneMapping = ACESFilmicToneMapping;
16 | renderer.toneMappingExposure = 1;
17 |
18 | const containerEl = document.querySelector('#container')!;
19 | containerEl.appendChild(renderer.domElement);
20 |
21 | const pmremGenerator = new PMREMGenerator(renderer);
22 | pmremGenerator.compileEquirectangularShader();
23 |
24 | const scene = new Scene();
25 |
26 | const light1 = new AmbientLight();
27 | const light2 = new DirectionalLight();
28 | light2.position.set(1, 2, 3);
29 | scene.add(light1, light2);
30 |
31 | createEnvironment(renderer)
32 | .then((environment) => {
33 | scene.environment = environment;
34 | scene.background = environment;
35 | render();
36 | });
37 |
38 | const camera = new PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.25, 20);
39 | camera.position.set(-1.8, 0.6, 2.7);
40 | camera.lookAt(scene.position);
41 |
42 | const controls = new OrbitControls(camera, renderer.domElement);
43 | controls.addEventListener('change', render);
44 | controls.minDistance = 2;
45 | controls.maxDistance = 10;
46 | controls.target.set(0, 0, - 0.2);
47 | controls.update();
48 |
49 | window.addEventListener( 'resize', onWindowResize );
50 |
51 | //
52 |
53 | let material: Material;
54 | let documentView: DocumentView;
55 |
56 | const pane = new Pane({title: 'DamagedHelmet.glb'});
57 | pane.registerPlugin(TweakpanePluginThumbnailList);
58 | const updateStats = createStatsPane(renderer, pane);
59 |
60 |
61 | const io = createIO();
62 | io.read('./DamagedHelmet.glb').then(async (doc) => {
63 | console.time('DocumentView::init');
64 | documentView = new DocumentView(doc);
65 | console.timeEnd('DocumentView::init');
66 |
67 | window['doc'] = doc;
68 | const modelDef = doc.getRoot().getDefaultScene() || doc.getRoot().listScenes()[0];
69 | const model = window['model'] = documentView.view(modelDef);
70 |
71 | scene.add(model);
72 | animate();
73 |
74 | // GUI.
75 |
76 | material = doc.getRoot().listMaterials().pop()!;
77 | createMaterialPane(pane, doc, material);
78 |
79 | const prim = doc.getRoot().listMeshes().pop()!.listPrimitives().pop()!;
80 | const primFolder = pane.addFolder({title: 'Primitive'});
81 | primFolder.addInput({mode: 4}, 'mode', {
82 | options: {
83 | POINTS: 0,
84 | LINES: 1,
85 | TRIANGLES: 4,
86 | }
87 | }).on('change', (ev) => {
88 | prim.setMode(ev.value as GLTF.MeshPrimitiveMode);
89 | });
90 |
91 | pane.addButton({title: 'stats'}).on('click', () => {
92 | documentView.gc();
93 | console.table(documentView.stats());
94 | });
95 | });
96 |
97 | //
98 |
99 | function animate() {
100 | requestAnimationFrame(animate);
101 | render();
102 | updateStats();
103 | }
104 |
105 | function render() {
106 | renderer.render(scene, camera);
107 | }
108 |
109 | function onWindowResize() {
110 | camera.aspect = window.innerWidth / window.innerHeight;
111 | camera.updateProjectionMatrix();
112 | renderer.setSize(window.innerWidth, window.innerHeight);
113 | render();
114 | }
115 |
--------------------------------------------------------------------------------
/src/observers/RefObserver.ts:
--------------------------------------------------------------------------------
1 | import type { Property as PropertyDef } from '@gltf-transform/core';
2 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
3 | import type { Subject } from '../subjects';
4 | import { Observable } from '../utils';
5 | import { EmptyParams } from '../pools';
6 |
7 | /**
8 | * Exposes a limited view of the RefObserver interface to objects
9 | * using it as an output socket.
10 | */
11 | export interface Output extends Observable {
12 | detach(): void;
13 | }
14 |
15 | /**
16 | * Observable connecting one Subject's output to another Subject's input.
17 | *
18 | * An Observer is subscribed to the values published by a particular Subject, and
19 | * passes those events along to a parent — usually another Subject. For example, a MaterialSubject
20 | * subscribes to updates from a TextureSubject using an Observer. Observers are parameterized:
21 | * for example, a single Texture may be used by many Materials, with different offset/scale/encoding
22 | * parameters in each. The TextureSubject treats each of these Observers as a different "output", and
23 | * uses the parameters associated with the Observer to publish the appropriate value.
24 | *
25 | * RefObserver should let the Subject call .next(), generally avoiding calling .next() itself. The
26 | * RefObserver is a passive pipe.
27 | *
28 | * @internal
29 | */
30 | export class RefObserver
31 | extends Observable
32 | implements Output
33 | {
34 | readonly name: string;
35 |
36 | private _subject: Subject | null = null;
37 | private _subjectParamsFn: () => Params = () => ({}) as Params;
38 |
39 | private readonly _documentView: DocumentViewSubjectAPI;
40 |
41 | constructor(name: string, documentView: DocumentViewSubjectAPI) {
42 | super(null);
43 | this.name = name;
44 | this._documentView = documentView;
45 | }
46 |
47 | /**************************************************************************
48 | * Child interface. (Subject (Child))
49 | */
50 |
51 | detach() {
52 | this._clear();
53 | }
54 |
55 | next(value: Value | null) {
56 | // Prevent publishing updates during disposal.
57 | if (this._documentView.isDisposed()) return;
58 |
59 | super.next(value);
60 | }
61 |
62 | /**************************************************************************
63 | * Parent interface. (Subject (Parent), ListObserver, MapObserver)
64 | */
65 |
66 | setParamsFn(paramsFn: () => Params): this {
67 | this._subjectParamsFn = paramsFn;
68 | return this;
69 | }
70 |
71 | getDef(): Def | null {
72 | return this._subject ? this._subject.def : null;
73 | }
74 |
75 | update(def: Def | null) {
76 | const subject = def ? (this._documentView.bind(def) as Subject) : null;
77 | if (subject === this._subject) return;
78 |
79 | this._clear();
80 |
81 | if (subject) {
82 | this._subject = subject;
83 | this._subject.addOutput(this, this._subjectParamsFn);
84 | this._subject.publish(this);
85 | } else {
86 | // In most cases RefObserver should let the Subject call .next() itself,
87 | // but this is the exception since the Subject is gone.
88 | this.next(null);
89 | }
90 | }
91 |
92 | /**
93 | * Forces the observed Subject to re-evaluate the output. For use when
94 | * output parameters are likely to have changed.
95 | */
96 | invalidate() {
97 | if (this._subject) {
98 | this._subject.publish(this);
99 | }
100 | }
101 |
102 | dispose() {
103 | this._clear();
104 | }
105 |
106 | /**************************************************************************
107 | * Internal.
108 | */
109 |
110 | private _clear() {
111 | if (this._subject) {
112 | this._subject.removeOutput(this);
113 | this._subject = null;
114 | }
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/DocumentView.ts:
--------------------------------------------------------------------------------
1 | import { Group, Material, Object3D, Texture } from 'three';
2 | import {
3 | Document,
4 | Scene as SceneDef,
5 | Node as NodeDef,
6 | Material as MaterialDef,
7 | Mesh as MeshDef,
8 | Primitive as PrimitiveDef,
9 | Property as PropertyDef,
10 | Texture as TextureDef,
11 | } from '@gltf-transform/core';
12 | import { Light as LightDef } from '@gltf-transform/extensions';
13 | import { DocumentViewConfig, DocumentViewImpl } from './DocumentViewImpl';
14 | import { LightLike, MeshLike } from './constants';
15 |
16 | /**
17 | * Constructs a three.js subtree from a glTF-Transform Document, and maintains a
18 | * 1:1 mapping between every three.js/glTF object pair. Supports full and partial
19 | * updates with significantly lower latency than serializing and reloading to
20 | * THREE.GLTFLoader each time.
21 | */
22 | export class DocumentView {
23 | /** @internal */ private _ready = false;
24 | /** @internal */ private _document: Document;
25 | /** @internal */ private _impl: DocumentViewImpl;
26 |
27 | /** Constructs a new DocumentView. */
28 | public constructor(document: Document, config = {} as DocumentViewConfig) {
29 | this._document = document;
30 | this._impl = new DocumentViewImpl(config);
31 | this._ready = true;
32 | }
33 |
34 | /**
35 | * For a given glTF-Transform Property definition, returns a corresponding
36 | * three.js view into the object. For example, given a glTF Transform scene,
37 | * returns a THREE.Group representing that scene. Repeated calls with the
38 | * same input will yield the same output objects.
39 | */
40 | public view(def: TextureDef): Texture;
41 | public view(def: LightDef): LightLike;
42 | public view(def: MaterialDef): Material;
43 | public view(def: PrimitiveDef): MeshLike;
44 | public view(def: MeshDef): Group;
45 | public view(def: NodeDef): Object3D;
46 | public view(def: SceneDef): Group;
47 | public view(def: PropertyDef): object {
48 | assert(this._ready);
49 | const value = this._impl.bind(def).value as Group;
50 | this._impl.recordOutputValue(def, value);
51 | return value;
52 | }
53 |
54 | /**
55 | * For a given source glTF-Transform Property definition, returns a list of rendered three.js
56 | * objects.
57 | */
58 | public listViews(source: TextureDef): Texture[];
59 | public listViews(source: LightDef): LightLike[];
60 | public listViews(source: MaterialDef): Material[];
61 | public listViews(source: PrimitiveDef): MeshLike[];
62 | public listViews(source: MeshDef): Group[];
63 | public listViews(source: NodeDef): Object3D[];
64 | public listViews(source: SceneDef): Group[];
65 | public listViews(source: PropertyDef): object[] {
66 | assert(this._ready);
67 | return this._impl.findValues(source as any);
68 | }
69 |
70 | /** For a given Object3D target, finds the source glTF-Transform Property definition. */
71 | public getProperty(view: Texture): TextureDef | null;
72 | public getProperty(view: LightLike): LightDef | null;
73 | public getProperty(view: Material): MaterialDef | null;
74 | public getProperty(view: MeshLike): PrimitiveDef | null;
75 | public getProperty(view: Object3D): MeshDef | NodeDef | SceneDef | null;
76 | public getProperty(view: object): PropertyDef | null {
77 | assert(this._ready);
78 | return this._impl.findDef(view as any);
79 | }
80 |
81 | public stats(): Record {
82 | assert(this._ready);
83 | return this._impl.stats();
84 | }
85 |
86 | public gc(): void {
87 | assert(this._ready);
88 | this._impl.gc();
89 | }
90 |
91 | /**
92 | * Destroys the renderer and cleans up its resources.
93 | *
94 | * Lifecycle: For resources associated with...
95 | * - ...used Properties, dispose with renderer.
96 | * - ...unused Properties, dispose with renderer.
97 | * - ...disposed Properties, dispose immediately.
98 | */
99 | public dispose(): void {
100 | assert(this._ready);
101 | this._impl.dispose();
102 | }
103 | }
104 |
105 | function assert(ready: boolean) {
106 | if (!ready) {
107 | throw new Error('DocumentView must be initialized before use.');
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/src/pools/MaterialPool.ts:
--------------------------------------------------------------------------------
1 | import { GLTF, Primitive as PrimitiveDef } from '@gltf-transform/core';
2 | import {
3 | LineBasicMaterial,
4 | Material,
5 | MeshBasicMaterial,
6 | MeshPhysicalMaterial,
7 | MeshStandardMaterial,
8 | PointsMaterial,
9 | } from 'three';
10 | import { Pool } from './Pool';
11 |
12 | export type BaseMaterial = MeshBasicMaterial | MeshStandardMaterial | MeshPhysicalMaterial;
13 | export type VariantMaterial =
14 | | MeshBasicMaterial
15 | | MeshStandardMaterial
16 | | MeshPhysicalMaterial
17 | | LineBasicMaterial
18 | | PointsMaterial;
19 |
20 | export interface MaterialParams {
21 | mode: GLTF.MeshPrimitiveMode;
22 | useVertexTangents: boolean;
23 | useVertexColors: boolean;
24 | useMorphTargets: boolean;
25 | useFlatShading: boolean;
26 | }
27 |
28 | /** @internal */
29 | export class MaterialPool extends Pool {
30 | static createParams(primitive: PrimitiveDef): MaterialParams {
31 | return {
32 | mode: primitive.getMode(),
33 | useVertexTangents: !!primitive.getAttribute('TANGENT'),
34 | useVertexColors: !!primitive.getAttribute('COLOR_0'),
35 | useFlatShading: !primitive.getAttribute('NORMAL'),
36 | useMorphTargets: primitive.listTargets().length > 0,
37 | };
38 | }
39 |
40 | requestVariant(srcMaterial: Material, params: MaterialParams): Material {
41 | return this._request(this._createVariant(srcMaterial as BaseMaterial, params));
42 | }
43 |
44 | protected _disposeValue(value: Material): void {
45 | value.dispose();
46 | super._disposeValue(value);
47 | }
48 |
49 | /** Creates a variant material for given source material and MaterialParams. */
50 | protected _createVariant(srcMaterial: BaseMaterial, params: MaterialParams): VariantMaterial {
51 | switch (params.mode) {
52 | case PrimitiveDef.Mode.TRIANGLES:
53 | case PrimitiveDef.Mode.TRIANGLE_FAN:
54 | case PrimitiveDef.Mode.TRIANGLE_STRIP:
55 | return this._updateVariant(srcMaterial, srcMaterial.clone(), params);
56 | case PrimitiveDef.Mode.LINES:
57 | case PrimitiveDef.Mode.LINE_LOOP:
58 | case PrimitiveDef.Mode.LINE_STRIP:
59 | return this._updateVariant(srcMaterial, new LineBasicMaterial(), params);
60 | case PrimitiveDef.Mode.POINTS:
61 | return this._updateVariant(srcMaterial, new PointsMaterial(), params);
62 | default:
63 | throw new Error(`Unexpected primitive mode: ${params.mode}`);
64 | }
65 | }
66 |
67 | /**
68 | * Updates a variant material to match new changes to the source material.
69 | *
70 | * NOTICE: Changes to MaterialParams should _NOT_ be applied with this method.
71 | * Instead, create a new variant and dispose the old if unused.
72 | */
73 | protected _updateVariant(
74 | srcMaterial: BaseMaterial,
75 | dstMaterial: VariantMaterial,
76 | params: MaterialParams,
77 | ): VariantMaterial {
78 | if (srcMaterial.type === dstMaterial.type) {
79 | dstMaterial.copy(srcMaterial);
80 | } else if (dstMaterial instanceof LineBasicMaterial) {
81 | Material.prototype.copy.call(dstMaterial, srcMaterial);
82 | dstMaterial.color.copy(srcMaterial.color);
83 | } else if (dstMaterial instanceof PointsMaterial) {
84 | Material.prototype.copy.call(dstMaterial, srcMaterial);
85 | dstMaterial.color.copy(srcMaterial.color);
86 | dstMaterial.map = srcMaterial.map;
87 | dstMaterial.sizeAttenuation = false;
88 | }
89 |
90 | dstMaterial.vertexColors = params.useVertexColors;
91 | if (dstMaterial instanceof MeshStandardMaterial) {
92 | dstMaterial.flatShading = params.useFlatShading;
93 | // https://github.com/mrdoob/three.js/issues/11438#issuecomment-507003995
94 | dstMaterial.normalScale.y = params.useVertexTangents
95 | ? Math.abs(dstMaterial.normalScale.y)
96 | : -1 * dstMaterial.normalScale.y;
97 | if (dstMaterial instanceof MeshPhysicalMaterial) {
98 | dstMaterial.clearcoatNormalScale.y = params.useVertexTangents
99 | ? Math.abs(dstMaterial.clearcoatNormalScale.y)
100 | : -1 * dstMaterial.clearcoatNormalScale.y;
101 | }
102 | }
103 |
104 | if (dstMaterial.version < srcMaterial.version) {
105 | dstMaterial.version = srcMaterial.version;
106 | }
107 |
108 | return dstMaterial;
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/src/ImageProvider.ts:
--------------------------------------------------------------------------------
1 | import { Texture as TextureDef } from '@gltf-transform/core';
2 | import { CompressedTexture, Texture, WebGLRenderer, REVISION } from 'three';
3 | import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
4 |
5 | const TRANSCODER_PATH = `https://unpkg.com/three@0.${REVISION}.x/examples/jsm/libs/basis/`;
6 |
7 | // Use a single KTX2Loader instance to pool Web Workers.
8 | function createKTX2Loader() {
9 | const renderer = new WebGLRenderer();
10 | const loader = new KTX2Loader().detectSupport(renderer).setTranscoderPath(TRANSCODER_PATH);
11 | renderer.dispose();
12 | return loader;
13 | }
14 |
15 | /** Generates a Texture from a Data URI, or otherh URL. */
16 | function createTexture(name: string, uri: string): Texture {
17 | const imageEl = document.createElement('img');
18 | imageEl.src = uri;
19 | const texture = new Texture(imageEl);
20 | texture.name = name;
21 | texture.flipY = false;
22 | return texture;
23 | }
24 |
25 | // Placeholder images.
26 | const NULL_IMAGE_URI =
27 | // eslint-disable-next-line max-len
28 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAAXNSR0IArs4c6QAAABNJREFUGFdj/M9w9z8DEmAkXQAAyCMLcU6pckIAAAAASUVORK5CYII=';
29 | const LOADING_IMAGE_URI =
30 | // eslint-disable-next-line max-len
31 | 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+P///38ACfsD/QVDRcoAAAAASUVORK5CYII=';
32 |
33 | export const NULL_TEXTURE = createTexture('__NULL_TEXTURE', NULL_IMAGE_URI);
34 | export const LOADING_TEXTURE = createTexture('__LOADING_TEXTURE', LOADING_IMAGE_URI);
35 |
36 | export interface ImageProvider {
37 | initTexture(textureDef: TextureDef): Promise;
38 | getTexture(textureDef: TextureDef): Promise;
39 | setKTX2Loader(loader: KTX2Loader): this;
40 | clear(): void;
41 | }
42 |
43 | export class NullImageProvider implements ImageProvider {
44 | async initTexture(_textureDef: TextureDef): Promise {}
45 | async getTexture(_: TextureDef): Promise {
46 | return NULL_TEXTURE;
47 | }
48 | setKTX2Loader(_loader: KTX2Loader): this {
49 | return this;
50 | }
51 | clear(): void {}
52 | }
53 |
54 | export class DefaultImageProvider implements ImageProvider {
55 | private _cache = new Map();
56 | private _ktx2Loader: KTX2Loader | null = null;
57 |
58 | async initTexture(textureDef: TextureDef): Promise {
59 | await this.getTexture(textureDef);
60 | }
61 |
62 | async getTexture(textureDef: TextureDef): Promise {
63 | const image = textureDef.getImage()!;
64 | const mimeType = textureDef.getMimeType();
65 |
66 | let texture = this._cache.get(image);
67 |
68 | if (texture) return texture;
69 |
70 | texture = mimeType === 'image/ktx2' ? await this._loadKTX2Image(image) : await this._loadImage(image, mimeType);
71 |
72 | this._cache.set(image, texture);
73 | return texture;
74 | }
75 |
76 | setKTX2Loader(loader: KTX2Loader): this {
77 | this._ktx2Loader = loader;
78 | return this;
79 | }
80 |
81 | clear(): void {
82 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
83 | for (const [_, texture] of this._cache) {
84 | texture.dispose();
85 | }
86 | this._cache.clear();
87 | }
88 |
89 | dispose() {
90 | this.clear();
91 | if (this._ktx2Loader) this._ktx2Loader.dispose();
92 | }
93 |
94 | /** Load PNG, JPEG, or other browser-suppored image format. */
95 | private async _loadImage(image: ArrayBuffer, mimeType: string): Promise {
96 | return new Promise((resolve, reject) => {
97 | const blob = new Blob([image], { type: mimeType });
98 | const imageURL = URL.createObjectURL(blob);
99 | const imageEl = document.createElement('img');
100 |
101 | const texture = new Texture(imageEl);
102 | texture.flipY = false;
103 |
104 | imageEl.src = imageURL;
105 | imageEl.onload = () => {
106 | URL.revokeObjectURL(imageURL);
107 | resolve(texture);
108 | };
109 | imageEl.onerror = reject;
110 | });
111 | }
112 |
113 | /** Load KTX2 + Basis Universal compressed texture format. */
114 | private async _loadKTX2Image(image: ArrayBuffer): Promise {
115 | this._ktx2Loader ||= createKTX2Loader();
116 | const blob = new Blob([image], { type: 'image/ktx2' });
117 | const imageURL = URL.createObjectURL(blob);
118 | const texture = await this._ktx2Loader.loadAsync(imageURL);
119 | URL.revokeObjectURL(imageURL);
120 | return texture;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/examples/2-material.ts:
--------------------------------------------------------------------------------
1 | import { ACESFilmicToneMapping, AmbientLight, DirectionalLight, PMREMGenerator, PerspectiveCamera, Scene, WebGLRenderer, TorusKnotGeometry } from 'three';
2 | import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
3 | import { Document, Material } from '@gltf-transform/core';
4 | import { DocumentView, NullImageProvider } from '../dist/view.modern.js';
5 | import { createMaterialPane } from './material-pane';
6 | import { createStatsPane } from './stats-pane.js';
7 | import { Pane } from 'tweakpane';
8 | import { createEnvironment } from './util.js';
9 |
10 | const renderer = new WebGLRenderer({antialias: true});
11 | renderer.setPixelRatio(window.devicePixelRatio);
12 | renderer.setSize(window.innerWidth, window.innerHeight);
13 | renderer.useLegacyLights = false;
14 | renderer.toneMapping = ACESFilmicToneMapping;
15 | renderer.toneMappingExposure = 1;
16 |
17 | const containerEl = document.querySelector('#container')!;
18 | containerEl.appendChild(renderer.domElement);
19 |
20 | const pmremGenerator = new PMREMGenerator(renderer);
21 | pmremGenerator.compileEquirectangularShader();
22 |
23 | const scene = new Scene();
24 |
25 | const light1 = new AmbientLight();
26 | const light2 = new DirectionalLight();
27 | light2.position.set(1, 2, 3);
28 | scene.add(light1, light2);
29 |
30 | createEnvironment(renderer)
31 | .then((environment) => {
32 | scene.environment = environment;
33 | scene.background = environment;
34 | render();
35 | });
36 |
37 | const camera = new PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.25, 20);
38 | camera.position.set(-4, 1.2, 5.4);
39 | camera.lookAt(scene.position);
40 |
41 | const controls = new OrbitControls(camera, renderer.domElement);
42 | controls.addEventListener('change', render);
43 | controls.minDistance = 2;
44 | controls.maxDistance = 10;
45 | controls.target.set(0, 0, - 0.2);
46 | controls.update();
47 |
48 | window.addEventListener('resize', onWindowResize);
49 |
50 | //
51 |
52 | let material: Material;
53 |
54 | const doc = (() => {
55 | const doc = new Document();
56 | material = doc.createMaterial('Material');
57 | const primTemplate = new TorusKnotGeometry(1, 0.4, 100, 16);
58 | const indicesArray = primTemplate.index!.array as Uint16Array;
59 | const positionArray = primTemplate.attributes.position.array as Float32Array;
60 | const normalArray = primTemplate.attributes.normal.array as Float32Array;
61 | const texcoordArray = primTemplate.attributes.uv.array as Float32Array;
62 | const prim = doc.createPrimitive()
63 | .setIndices(doc.createAccessor('indices').setType('SCALAR').setArray(indicesArray))
64 | .setAttribute('POSITION', doc.createAccessor('p').setType('VEC3').setArray(positionArray))
65 | .setAttribute('NORMAL', doc.createAccessor('n').setType('VEC3').setArray(normalArray))
66 | .setAttribute('TEXCOORD_0', doc.createAccessor('t').setType('VEC2').setArray(texcoordArray))
67 | .setMaterial(material);
68 | const mesh = doc.createMesh().addPrimitive(prim);
69 | const node = doc.createNode().setMesh(mesh);
70 | doc.createScene().addChild(node);
71 | return doc;
72 | })();
73 |
74 | const imageProvider = new NullImageProvider();
75 | const documentView = new DocumentView(doc, {imageProvider});
76 | const modelDef = doc.getRoot().getDefaultScene() || doc.getRoot().listScenes()[0];
77 | const model = documentView.view(modelDef);
78 | scene.add(model);
79 |
80 | //
81 |
82 | const pane = new Pane({title: 'DamagedHelmet.glb'});
83 | createMaterialPane(pane, doc, material);
84 | const updateStats = createStatsPane(renderer, pane);
85 |
86 | //
87 |
88 | animate();
89 |
90 | //
91 |
92 | function animate() {
93 | requestAnimationFrame(animate);
94 | render();
95 | updateStats();
96 | }
97 |
98 | function render() {
99 | renderer.render(scene, camera);
100 | }
101 |
102 | function onWindowResize() {
103 | camera.aspect = window.innerWidth / window.innerHeight;
104 | camera.updateProjectionMatrix();
105 | renderer.setSize(window.innerWidth, window.innerHeight);
106 | render();
107 | }
108 |
109 | function printGraph(node) {
110 | console.group(' <' + node.type + '> ' + node.name + '#' + node.uuid.substr(0, 6));
111 | node.children.forEach((child) => printGraph(child));
112 | if (node.isMesh) {
113 | console.group(' <' + node.geometry.type + '> ' + node.geometry.name + '#' + node.geometry.uuid.substr(0, 6));
114 | console.groupEnd();
115 | console.group(' <' + node.material.type + '> ' + node.material.name + '#' + node.material.uuid.substr(0, 6));
116 | console.groupEnd();
117 | }
118 | console.groupEnd();
119 | }
120 |
--------------------------------------------------------------------------------
/src/subjects/Subject.ts:
--------------------------------------------------------------------------------
1 | import { Property as PropertyDef } from '@gltf-transform/core';
2 | import { Output } from '../observers';
3 | import type { DocumentViewSubjectAPI } from '../DocumentViewImpl';
4 | import type { Subscription, THREEObject } from '../constants';
5 | import { EmptyParams, ValuePool } from '../pools';
6 |
7 | /**
8 | * Implementation of BehaviorSubject pattern, emitting three.js objects when changes
9 | * occur in glTF definitions.
10 | *
11 | * Each glTF definition (e.g. `Material`) is bound to a single Subject (e.g. `MaterialSubject`).
12 | * The Subject is responsible for receiving change events published by the definition, generating a
13 | * derived three.js object (e.g. `THREE.Material`), and publishing the new value to all Observers. More
14 | * precisely, this is a [*BehaviorSubject*](https://reactivex.io/documentation/subject.html), which holds
15 | * a single current value at any given time.
16 | *
17 | * @internal
18 | */
19 | export abstract class Subject {
20 | def: Def;
21 | value: Value;
22 | pool: ValuePool;
23 |
24 | protected _documentView: DocumentViewSubjectAPI;
25 | protected _subscriptions: Subscription[] = [];
26 | protected _outputs = new Set