@drawcall/glts
v0.10.0
Published
Like glTF, but for procedural Three.js assets.
Maintainers
Readme
GLTS
Like glTF, but for procedural Three.js assets.
GLTS loads trusted, exportless TypeScript scripts into native Three.js scenes. Scripts build scenes with ordinary Three.js APIs and can compose other GLTS assets explicitly through a contextual loader.
import * as THREE from "three"
import { onDispose, scene } from "@drawcall/glts"
const geometry = new THREE.IcosahedronGeometry()
const material = new THREE.MeshStandardMaterial({ color: "orange" })
scene.add(new THREE.Mesh(geometry, material))
onDispose(() => {
geometry.dispose()
material.dispose()
})GLTS is executable code, not a sandbox or serialization format. Only load code you trust.
Viewer
Run pnpm dev to open the example viewer. It accepts a trusted, self-contained
.glts file by drag and drop or file picker and includes a bundled multi-file
vintage racecar.
Load a scene
Install GLTS next to the application's Three.js dependency:
pnpm add @drawcall/glts threeThe current physics integration supports Three.js r185 (>=0.185.0 <0.186.0).
import { GLTSLoader } from "@drawcall/glts"
import * as THREE from "three"
const loadingManager = new THREE.LoadingManager()
const loader = new GLTSLoader(loadingManager)
const tree = await loader.loadAsync("/assets/tree.glts")
scene.add(tree) // A loaded scene is also an ordinary Object3D subtree.
tree.update(deltaSeconds)
await tree.reload()
tree.dispose()
loader.dispose()The result is a stable THREE.Scene with seven additions:
url: canonical source URL;capabilities: read-only dependency declarations, includingphysics;reload(): executes the latest source while preserving scene identity;update(delta): dispatches registered frame callbacks;dispose(): releases this scene and its nested GLTS scenes;defaultCamera: optional authored camera used byGLTSRendererwhen the host omits one;rendering: renderer settings used byGLTSRendererwhen this scene is the render root.
Native scene properties such as background, environment, fog, and
overrideMaterial work directly. clone() remains the ordinary Three.js clone
operation and produces an unmanaged snapshot.
load() has the familiar Three.js callback shape. load(), loadAsync(),
loadInstancesAsync(), and reload() accept strings or URL objects. Track
aggregate progress with loadingManager.onProgress; per-load progress callbacks
are rejected because nested GLTS and resource requests form one manager-owned
loading graph.
Compose scripts
Scripts do not import other .glts files statically. They use the contextual
gltsLoader instance:
import { gltsLoader, scene } from "@drawcall/glts"
const branch = await gltsLoader.loadAsync(
new URL("./branch.glts", import.meta.url),
)
scene.add(branch)Every nested result must be added to the current script's scene before the
script finishes. Unattached results fail the parent load and are disposed.
The host creates an execution-scoped recursive loader backed by the same module
cache, lifecycle, live-node registry, and host loading manager. The scope makes
nested ownership unambiguous when scripts execute concurrently. Explicit URL
construction keeps relative resolution correct; .href is not needed by GLTS
methods.
Script context
The supported runtime imports are:
import {
gltsLoader,
instanceCount,
loadingManager,
onDispose,
onFrame,
onMatrixUpdateAt,
isPreview,
scene,
} from "@drawcall/glts"GLTS rewrites these imports for each execution. scene, counts, flags, and
callbacks never leak between concurrently executing scripts.
Scripts may use top-level await. Work started during script evaluation
through Three.js loaders using the contextual loadingManager is included in
that script's completion, including work started without awaiting its promise.
Notifications are forwarded to the host manager without mixing failures
between concurrent scripts.
Host URL modifiers are respected. Extension handlers must be registered with the contextual manager and constructed with it; host-bound handler instances cannot be reused safely across concurrent script scopes.
Frame updates and cleanup
let elapsed = 0
onFrame((delta) => {
elapsed += delta
mesh.rotation.y = elapsed
})
onDispose(() => {
texture.dispose()
geometry.dispose()
material.dispose()
})Updating a parent scene also updates managed descendants. GLTS calls disposal callbacks in reverse registration order. It does not infer resource ownership.
Root presentation and preview staging
Scene properties and rendering describe this scene when it is the render root.
Use isPreview to gate staging that exists only for standalone inspection:
import * as THREE from "three"
import { isPreview, onDispose, scene } from "@drawcall/glts"
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js"
scene.defaultCamera = new THREE.PerspectiveCamera(35, 1, 0.1, 100)
scene.defaultCamera.position.set(4, 3, 6)
scene.defaultCamera.lookAt(0, 1, 0)
if (isPreview) {
const floor = new THREE.Mesh(
new THREE.CircleGeometry(20),
new THREE.MeshStandardMaterial({ color: "#333333" }),
)
floor.rotation.x = -Math.PI / 2
floor.receiveShadow = true
const light = new THREE.DirectionalLight("white", 3)
light.position.set(4, 6, 3)
light.castShadow = true
scene.background = new THREE.Color("#171b2b")
scene.fog = new THREE.Fog("#171b2b", 8, 40)
scene.add(light, light.target, floor)
scene.rendering.shadows = true
scene.rendering.toneMapping = THREE.ACESFilmicToneMapping
scene.rendering.toneMappingExposure = 1.1
scene.rendering.effects.push(({ height, width }) =>
new UnrealBloomPass(new THREE.Vector2(width, height), 0.3, 0.4, 0.85),
)
onDispose(() => {
light.dispose()
floor.geometry.dispose()
floor.material.dispose()
})
}Set castShadow on intrinsic meshes outside the preview block when they should
cast in any lighting setup; enabling renderer shadows alone creates none.
Use new GLTSLoader(manager, { isPreview: true }) in a preview application.
Only loads initiated through that host loader can receive isPreview === true.
Every contextual nested load receives false at every depth, and keeps that
value across reloads, so standalone presentation cannot leak into composition.
An asset may recommend a camera through scene.defaultCamera. It need not be
added to the scene graph unless it depends on an authored parent. The preview
application decides whether to use it or supply another camera; GLTS never
searches the scene hierarchy for one.
scene.rendering describes the renderer state needed to present this root:
shadowslocalClippingEnabledtoneMappingtoneMappingExposureeffects, an array of factories returning Three.js post-processingPassinstances compatible withWebGLRenderer.setEffects()
Factories receive the selected camera, loaded scene, and initial
drawing-buffer width and height. The selected camera is the explicit host
camera, or scene.defaultCamera when the host omits one. Use the supplied scene
and camera for effects that need them. Do not add RenderPass or OutputPass:
GLTS renders the beauty pass and final output itself. The adapter owns and
disposes the returned passes. The optional third argument to render() is
forwarded to passes as their frame delta.
The physical renderer, WebGL context, canvas, output color space, shadow-map filtering, viewport, camera selection and projection, controls, and frame loop remain application-owned. A preview host opts into the rendering profile explicitly:
import { GLTSLoader, GLTSRenderer } from "@drawcall/glts"
import * as THREE from "three"
const renderer = new THREE.WebGLRenderer({
...GLTSRenderer.parameters,
canvas,
})
const gltsRenderer = new GLTSRenderer(renderer)
const loader = new GLTSLoader(manager, { isPreview: true })
const root = await loader.loadAsync("/asset.glts")
root.update(delta)
gltsRenderer.render(root, undefined, delta)
root.dispose()
gltsRenderer.dispose()Omitting the camera uses root.defaultCamera; rendering fails if neither is
available. Passing a camera as the second argument always overrides the
authored default. defaultCamera is qualified because render() accepts a
competing camera argument; native scene properties have no competing argument.
One GLTSRenderer can render different root assets sequentially across frames
while reusing the same WebGL context. It applies each root's settings for that
call, then restores the host renderer's settings. It exclusively manages
renderer.setEffects() for its lifetime: create only one adapter per physical
renderer and do not call setEffects() elsewhere. Construct the WebGL renderer
with GLTSRenderer.parameters when effects are used; it selects the HDR output
buffer required by Three.js. GLTS cannot inspect how an existing renderer was
constructed.
V1 effects and HDR tone mapping require the default framebuffer, a full
viewport, and disabled scissor testing; render-target and split-viewport
pipelines remain application-owned.
Changing the camera rebuilds that root's passes. Call release(scene) before
disposing an effect-bearing scene if the adapter remains alive;
GLTSRenderer.dispose() releases every cached pass. XR is outside V1. If the
host reads renderer.info, set info.autoReset = false and reset it once per
frame because post-processing performs nested render calls.
For embedding, renderer.render(asset, camera) and custom pipelines still work
because the result is a native scene. They intentionally ignore
asset.rendering; only the root scene passed to GLTSRenderer.render() owns
presentation. A nested asset's background, environment, fog,
override material, default camera, and rendering profile do not override its
parent.
V1 intentionally targets Three.js WebGL post-processing. PMNDRS composers,
Three.js WebGPU/TSL, TypeGPU, and other custom pipelines can consume the native
scene directly, but are not translated into scene.rendering.
Load instances
The promoted instancing API mirrors Three.js naming:
const rocks = await loader.loadInstancesAsync("/assets/rock.glts", 1000)
const matrix = new THREE.Matrix4()
for (let index = 0; index < rocks.count; index += 1) {
matrix.makeTranslation(index * 2, 0, 0)
rocks.setMatrixAt(index, matrix)
}
scene.add(rocks)GLTSInstances is a distinct addable scene with immutable count,
getMatrixAt(), setMatrixAt(), reload(), update(), and dispose().
Ordinary mesh scripts are automatically converted to THREE.InstancedMesh
objects. A script can implement native instancing when it needs special
handling:
import * as THREE from "three"
import {
instanceCount,
onDispose,
onMatrixUpdateAt,
scene,
} from "@drawcall/glts"
const mesh = new THREE.InstancedMesh(geometry, material, instanceCount)
scene.add(mesh)
onMatrixUpdateAt((index, matrix) => {
mesh.setMatrixAt(index, matrix)
mesh.instanceMatrix.needsUpdate = true
})
onDispose(() => {
mesh.dispose()
geometry.dispose()
material.dispose()
})Registering onMatrixUpdateAt selects native instancing. Current matrices are
replayed at registration, and later outer setMatrixAt() calls are forwarded
immediately. The script executes once, whether GLTS uses native or automatic
instancing. Reload preserves count and matrices and may switch implementation.
Automatic instancing rejects animated, composed, skinned, already-instanced, and other non-mesh renderable hierarchies. Such scripts must implement native instancing instead of receiving an approximate result.
Reload and errors
Reload builds the replacement before changing the live node. Fetch, compilation, import, or execution failure leaves the current content mounted. The node's identity and application-owned transform survive successful reload; authored root metadata, default camera, and render state refresh with the new revision.
loader.reload(url) clears cached source and updates every undisposed node loaded from that URL. If there are none, it does no fetching or scene work; the next load fetches current source. Pending loads finish before invalidation. URLs use the same resolution as loading, and each resolved request URL has its own cache entry. The application decides when a preview is inactive and disposes its nodes.
Uncached GLTS source requests use HTTP no-cache, including the first load, so the server validates freshness before source enters the loader's cache. Subsequent loads share the cached promise until invalidation. External module caching is unchanged.
Failures are GLTSError values with url, phase, and cause. GLTS reports
source and resource requests through the supplied Three.js loading manager.
Script restrictions
A .glts file:
- exports nothing;
- imports contextual values by name from
@drawcall/glts; - cannot import or construct
GLTSLoader; - loads nested
.gltsfiles withgltsLoader; - cannot call
gltsLoader.reload(); reload is a host/live-node operation; - may import
three, Three.js addons, and browser-compatible npm packages; - may not use dynamic imports, import attributes, or local helper modules.
three always resolves to the host application's exact namespace. Three.js
addons and bare npm packages are fetched through ESM.sh with three redirected
to that host namespace.
The page must allow CORS access to sources and dependencies and permit blob:
ES modules in its CSP.
Workspace
pnpm install
pnpm dev
pnpm check
pnpm test:e2ePackage tests live in packages/glts/test, beside src; browser tests live in
examples/viewer/e2e. Both are included in strict TypeScript checks.
Browser tests start their own server. If port 5173 is occupied, use
GLTS_E2E_PORT=5187 pnpm test:e2e to select another port.
Agent skill
Install the GLTS authoring guidance for your coding agent:
npx skills add drawcall-ai/glts --skill gltsPhysics and USD
Physics authoring lives in the separate @drawcall/physics library:
import * as THREE from "three"
import { scene } from "@drawcall/glts"
import { RigidBody } from "@drawcall/physics"
const body = new RigidBody({ mass: 2 })
body.add(new THREE.Mesh(
new THREE.BoxGeometry(),
new THREE.MeshStandardMaterial(),
))
scene.add(body)The host sets up a world before loading a physical asset:
import { setupWorld } from "@drawcall/physics-rapier"
import { GLTSLoader } from "@drawcall/glts"
const world = await setupWorld()
const loader = new GLTSLoader(manager)
const asset = await loader.loadAsync("/body.glts")
// Each frame: world.update(delta), asset.update(delta), then render.For authoring/export without simulation, create an AuthoringWorld from
@drawcall/physics and pass { physicsWorld: world } as the loader's second
argument. The same option selects a particular simulation world when a host
uses several worlds.
GLTS lazily loads the authoring library. Each execution gets constructors bound
to its selected world; objects retain the host library's instanceof behavior.
The world is selected when the first physics dependency resolves, inherited by
nested loads, and retained across reloads and asynchronous/frame callbacks.
Inside an asset, getDefaultWorld() returns that execution's world. Host world
setup functions should be called outside asset scripts.
CDN helpers importing physics get the same execution binding. Physics-dependent module instances are isolated per execution; fetched source and physics-free module instances remain cached. Missing world setup throws rather than silently creating a world.
Bodies and joints stay outside the simulation while an asset or its nested loads
are being constructed. A successful load registers the complete hierarchy; a
reload keeps the previous physics active until replacement commits. Attach and
configure the returned scene synchronously after await loader.loadAsync(...),
before the next world.update(), including update(0).
Physics captures scale during preparation, including the host's final parent
transform. Do not step the world inside the asset's construction script. Objects
created later by onFrame() register normally for the next preparation boundary.
A scene owns every physics object constructed by its execution, including objects
not added to the scene tree and objects created with .clone() or clone(root).
Disposal unregisters them automatically. Failed loads/reloads clean up their new
objects while retaining the current asset. The host still owns the world and
must dispose it when finished. Configure body mass/type and joint frames/limits through constructors.
Set velocity, material, and connected-body contact through setVelocity,
setMaterial, and setCollideConnected. The authoring skill
covers physics asset construction and animation. body.getColliders() returns the actual explicit or automatically
generated collider objects. Bodies and
joints expose .validate(); joint.getFrame(index, matrix) writes a body-local
joint frame into a Three.js Matrix4.
Use ordinary Three.js groups to organize mechanisms. The clone(root) helper
from @drawcall/physics clones the hierarchy and reconnects internal joint
references to the cloned bodies, preserving references to bodies outside the
hierarchy. Individual body/joint .clone() and .copy() methods retain their
ordinary object-level roles; clones belong to the same world.
A managed asset exposes read-only asset.capabilities.physics. This declares a
runtime import dependency, not the presence of a particular body: even a
side-effect-only physics import declares the capability. Transitive package
imports and owned nested GLTS loads contribute to it. A nested reload updates
the parent's effective declaration; a failed reload preserves the current
revision. Type-only imports do not declare runtime physics. Manually inserted
physics objects from host code do not declare a script dependency; use the
physics exporter directly for such ordinary Three.js scenes.
import { GLTSUSDExporter } from "@drawcall/glts"
const exporter = new GLTSUSDExporter()
const usdz = await exporter.parseAsync(asset)The exporter selects Three.js's native USDZ exporter for visual assets. A
physics declaration lazily loads @drawcall/physics-usd, which exports the
visuals and resolved physical shapes, bodies, materials, and joints. Export
does not initialize a simulation backend. Export an authored/reset scene,
not a scene currently displaying simulated poses. Import of the supported USD
Physics subset is available separately through PhysicsUSDLoader from
@drawcall/physics-usd.
Simulation is provided by @drawcall/physics-rapier; it is not required for
loading or exporting an asset. Physics instancing is currently rejected:
load independent scenes until per-instance body ownership is supported.
Runnable physics example
See examples/physics for a simulated ragdoll with automatic
colliders, ball joints, limited elbows and knees, reset, and USDZ export.
