PBR Materials & KTX2 Textures
Captic keeps PBR materials in a material library on the backend. A material follows the glTF 2.0 metallic-roughness model:
- Source maps are uploaded per slot (
color_map,normal_map,orm_map, ...). - Parameters hold the glTF factors and settings (
baseColorFactor,roughnessFactor,alphaMode, ...). - Runtime textures (
base_color,orm,normal,emissive,displacement,sky) are encoded on the server as.ktx2files with Basis Universal 2.50 (XUASTC LDR by default, plus XUBC7, UASTC or ETC1S on request). Clients request encodes; they never upload encoded files. - Stable links on the CDN (
/m/<material_id>/<texture>.ktx2and the/m/<material_id>.jsonmanifest) always point to the current files, so an edit to the material reaches every world that uses it without changing a URL.
When a material is created, the server also builds a classic set for the color, normal, roughness, metalness and sky maps: scaled WebP images and KTX2 files (ETC1S or UASTC) at 512, 1k and 2k, plus 4k and 8k when the source is at least that large. The classic set is the fallback when no encoded variant is ready.
Why KTX2?
- GPU memory savings: PNG, JPEG and WebP are decoded to uncompressed RGBA in GPU memory. KTX2 textures stay block-compressed (ASTC, BC7, ETC2, ...) on the GPU.
- Fast load times: supercompressed files are small to download.
- Mipmaps included: the encoder writes the full mip chain, so the browser does not generate mipmaps at runtime.
1. Texture slots and three.js mapping
Each runtime texture is built from one or more source slots and feeds these three.js material properties:
| Runtime texture | Source slots | three.js property | Color space | Notes |
|---|---|---|---|---|
base_color | color_map, opacity_map | map | sRGB (SRGBColorSpace) | opacity_map (red channel) becomes the alpha channel |
orm | orm_map, or occlusion_map + roughness_map + metalness_map | aoMap (R), roughnessMap (G), metalnessMap (B) | Linear (NoColorSpace) | One texture for all three. A missing channel is white, so the factor alone sets the value |
normal | normal_map | normalMap | Linear (NoColorSpace) | Always OpenGL convention (Y+). A DirectX map is converted when normal_convention is directx |
emissive | emissive_map | emissiveMap | sRGB (SRGBColorSpace) | |
displacement | displacement_map | displacementMap | Linear (NoColorSpace) | Needs a finely subdivided mesh |
sky | sky_map | Sky background | sRGB | Equirectangular panorama. A material with a sky_map is a sky material |
Grey source maps (occlusion, roughness, metalness, opacity, displacement) are read from their red channel.
If you upload orm_map, it wins over the separate occlusion, roughness and metalness maps.
Set color textures (base_color, emissive) to sRGB and data textures (orm, normal, displacement) to
Linear (NoColorSpace). The wrong color space distorts colors and lighting.
Parameters
parameters uses the glTF 2.0 / KHR_materials_* names. The factors multiply the textures; without a texture, the
factor is the value.
| Parameter | Type / range | Default | three.js property |
|---|---|---|---|
baseColorFactor | [r, g, b, a], linear, 0–1 | [1, 1, 1, 1] | color, opacity |
metallicFactor | 0–1 | 1 with a metalness or ORM source, else 0 | metalness |
roughnessFactor | 0–1 | 1 | roughness |
normalScale | -10–10 | 1 | normalScale |
occlusionStrength | 0–1 | 1 | aoMapIntensity |
emissiveFactor | [r, g, b], linear, 0–1 | [1, 1, 1] with an emissive map, else [0, 0, 0] | emissive |
emissiveStrength | 0–1000 | 1 | emissiveIntensity |
alphaMode | OPAQUE, MASK, BLEND | MASK with an opacity map, else OPAQUE | transparent (BLEND), alphaTest (MASK) |
alphaCutoff | 0–1 | 0.5 | alphaTest when alphaMode is MASK |
doubleSided | boolean | false | side |
displacementScale | -100–100 (metres) | 0.02 | displacementScale |
displacementBias | -100–100 (metres) | 0 | displacementBias |
Unknown keys or out-of-range values are rejected with 422. The API returns the stored values in parameters
and the values with defaults applied in effective_parameters.
2. Using materials in a Captic script
Inside a CapElement script, load a library material by its manifest link with this.loadMaterial(). It uses the
platform's KTX2 loader (which decodes XUASTC and XUBC7), falls back to the WebP link if a KTX2 file fails, applies
the parameters, and disposes the textures when the script is removed.
class TiledFloor_3f9c2e1a extends CapElement {
async init() {
// manifest link from the material library, e.g. from get_material / save_material
const material = await this.loadMaterial('https://cdn-west4.w3dge.io/m/material_xxxxxxxx.json', {
displacement: false // skip the displacement texture on coarse meshes
});
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(10, 10), material);
mesh.rotation.x = -Math.PI / 2;
this.el.setObject3D('mesh', mesh);
}
}
export default TiledFloor_3f9c2e1a;
Options:
| Option | Effect |
|---|---|
physical: true | Build a MeshPhysicalMaterial instead of a MeshStandardMaterial |
displacement: false | Skip the displacement texture |
size: [width, height] | Metres covered by a surface with 0–1 UVs. Tiling textures repeat at their real-world tile size |
worldScale: false | Keep the mesh's UVs and set the repeat yourself |
quality: 'high' | Keep the material PBR on every device. By default it follows the room's material quality, like glTF models (Phong on medium, Lambert on low) |
By default, tiling textures (floors, plaster, wood, metal) are placed by the object's own size in metres instead of its UVs, so they show at their real-world scale on any mesh. Setting a repeat on one of the textures switches the material back to the mesh's UVs.
The world's ground can use a library material too: set useGround.mode to "material" and
useGround.material.manifest to the manifest link (see Scene Description File).
3. Using materials with three.js outside Captic
Outside the platform, read the manifest and load each texture with your own loaders.
The bare .ktx2 link serves the preferred encode, which is XUASTC LDR by default. The Basis transcoder that ships
with three.js (three/examples/jsm/libs/basis/) only decodes ETC1S and UASTC. Without a 2.50+ transcoder, pick a
uastc_ldr or etc1s variant from the manifest (request one with the full preset), or use the .webp link.
import * as THREE from 'three';
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
const renderer = new THREE.WebGLRenderer({ antialias: true });
const ktx2Loader = new KTX2Loader()
.setTranscoderPath('/js/libs/basis/') // basis_transcoder.js + .wasm
.detectSupport(renderer);
const imageLoader = new THREE.TextureLoader();
// Encodings the stock three.js transcoder can decode
const SUPPORTED = ['uastc_ldr', 'etc1s'];
async function loadTexture(entry, manifestUrl, yFlip) {
const variant = entry.variants.find((v) => SUPPORTED.includes(v.encoding));
let texture;
if (variant) {
texture = await ktx2Loader.loadAsync(new URL(variant.link, manifestUrl).href);
} else {
texture = await imageLoader.loadAsync(new URL(entry.links.webp, manifestUrl).href);
texture.flipY = !!yFlip; // match the KTX2 orientation
}
texture.colorSpace = entry.color_space === 'srgb' ? THREE.SRGBColorSpace : THREE.NoColorSpace;
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
return texture;
}
async function loadLibraryMaterial(manifestUrl) {
const manifest = await (await fetch(manifestUrl)).json();
const t = {};
for (const [name, entry] of Object.entries(manifest.textures)) {
if (name === 'sky') continue;
t[name] = await loadTexture(entry, manifestUrl, manifest.y_flip);
}
const p = manifest.parameters; // defaults already applied
return new THREE.MeshStandardMaterial({
map: t.base_color || null,
color: new THREE.Color().setRGB(...p.baseColorFactor.slice(0, 3)),
opacity: p.baseColorFactor[3],
aoMap: t.orm || null,
roughnessMap: t.orm || null,
metalnessMap: t.orm || null,
aoMapIntensity: p.occlusionStrength,
roughness: p.roughnessFactor,
metalness: p.metallicFactor,
normalMap: t.normal || null,
normalScale: new THREE.Vector2(p.normalScale, p.normalScale),
emissiveMap: t.emissive || null,
emissive: new THREE.Color().setRGB(...p.emissiveFactor),
emissiveIntensity: p.emissiveStrength,
displacementMap: t.displacement || null,
displacementScale: p.displacementScale,
displacementBias: p.displacementBias,
transparent: p.alphaMode === 'BLEND',
alphaTest: p.alphaMode === 'MASK' ? p.alphaCutoff : 0,
side: p.doubleSided ? THREE.DoubleSide : THREE.FrontSide
});
}
The classic KTX2 files (color_map_ktx2_1k_url, normal_map_ktx2_1k_url, ... on the material record) are ETC1S
or UASTC and also load with the stock three.js transcoder.
4. REST API
The material API lives under /api/v1/materials on the API host (for example https://api-west4.w3dge.io).
Authentication
Send a JWT or an API key:
Authorization: Bearer <your_access_token>X-Api-Key: <your_api_key>
Creating, updating and deleting need authentication. Without signing in, you can read public and unlisted
materials and list public ones.
Visibility
visibility decides who can find and reuse a material in the library:
| Value | Who can see it |
|---|---|
private (default) | The creator only |
organization | Members of its organization. Requires organization_id |
unlisted | Anyone who has its ID. Never listed |
public | Listed in the public library. Readable without signing in |
The texture files and stable links stay loadable for everyone, whatever the visibility, because every visitor of a world that uses a material must load its textures. The creator can edit, re-encode and delete a material. For a material filed under an organization, the organization's owners and admins can too.
Create a material
Creates a material, builds the classic set and queues the default encode of every runtime texture.
- Endpoint:
POST /api/v1/materials - Content-Type:
multipart/form-data(or JSON without files)
| Parameter | Type | Required | Description |
|---|---|---|---|
material[name] | String | Yes | Name of the material |
material[parameters] | JSON object (a JSON string in multipart) | No | glTF parameters, see Parameters |
material[visibility] | String | No | private (default), organization, unlisted or public |
material[organization_id] | String | No | Organization (org_...) to file the material under. You must be a member |
material[normal_convention] | String | No | opengl (default) or directx. DirectX normal maps get their green channel inverted when encoded |
material[y_flip] | Boolean | No | Store every encode bottom-up (for textures captured with flipY, such as canvas textures). Default false |
material[metadata] | JSON object (a JSON string in multipart) | No | Free-form notes, such as how the maps were made. Up to 256 KB |
material[color_map] | File | No | Base color (sRGB) |
material[opacity_map] | File | No | Alpha (grey) |
material[normal_map] | File | No | Tangent-space normal map |
material[orm_map] | File | No | Packed R = occlusion, G = roughness, B = metalness |
material[occlusion_map] | File | No | Ambient occlusion (grey) |
material[roughness_map] | File | No | Roughness (grey) |
material[metalness_map] | File | No | Metalness (grey) |
material[emissive_map] | File | No | Emission (sRGB) |
material[displacement_map] | File | No | Surface height (grey) |
material[sky_map] | File | No | Equirectangular sky panorama |
material[height_map] | File | No | Terrain elevation. Stored as lossless WebP at 256 to 4k, no KTX2 |
material[splat_map] | File | No | Terrain splat map. No runtime texture |
Example request
curl -X POST https://api-west4.w3dge.io/api/v1/materials \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-F "material[name]=Polished Iron" \
-F 'material[parameters]={"roughnessFactor":0.25,"metallicFactor":0.95}' \
-F "material[visibility]=unlisted" \
-F "material[color_map]=@/path/to/iron_albedo.png" \
-F "material[normal_map]=@/path/to/iron_normal.png" \
-F "material[roughness_map]=@/path/to/iron_roughness.png"
Example response (201 Created, abbreviated)
{
"material": {
"prefix_id": "material_3kTMd9Lq",
"name": "Polished Iron",
"status": "pending",
"visibility": "unlisted",
"organization_prefix_id": null,
"normal_convention": "opengl",
"y_flip": false,
"content_version": 1,
"parameters": { "roughnessFactor": 0.25, "metallicFactor": 0.95 },
"effective_parameters": {
"baseColorFactor": [1.0, 1.0, 1.0, 1.0],
"metallicFactor": 0.95,
"roughnessFactor": 0.25,
"normalScale": 1.0,
"occlusionStrength": 1.0,
"emissiveFactor": [0.0, 0.0, 0.0],
"emissiveStrength": 1.0,
"alphaMode": "OPAQUE",
"alphaCutoff": 0.5,
"doubleSided": false,
"displacementScale": 0.02,
"displacementBias": 0.0
},
"available_textures": ["base_color", "orm", "normal"],
"color_map_url": "/rails/active_storage/blobs/proxy/.../iron_albedo.png",
"color_map_ktx2_1k_url": null,
"variants": [
{ "prefix_id": "mvar_8sQwz1Pa", "texture": "base_color", "encoding": "xuastc_ldr", "block": "4x4", "quality": 85, "status": "pending", "preferred": true }
],
"links": {
"base_color": { "ktx2": "/m/material_3kTMd9Lq/base_color.ktx2", "webp": "/m/material_3kTMd9Lq/base_color.webp" },
"orm": { "ktx2": "/m/material_3kTMd9Lq/orm.ktx2", "webp": "/m/material_3kTMd9Lq/orm.webp" },
"normal": { "ktx2": "/m/material_3kTMd9Lq/normal.ktx2", "webp": "/m/material_3kTMd9Lq/normal.webp" }
},
"manifest_path": "/m/material_3kTMd9Lq.json",
"metadata": {}
}
}
The record also lists every classic file: <slot>_url, <slot>_<size>_url (WebP) and <slot>_ktx2_<size>_url for
color_map, normal_map, roughness_map, metalness_map, splat_map and sky_map at 512, 1k, 2k, 4k
and 8k, plus material_preview_url (512 px) and material_preview_thumb_url (256 px).
Right after creation, status is pending or processing and the classic URLs are null. status becomes
ready once the classic set and previews are built. Each variant has its own status. The stable links serve a
texture as soon as one encode of it is ready.
Errors answer 422 with { "error": ["Name can't be blank", ...] }.
Get a material
- Endpoint:
GET /api/v1/materials/:id
:id is the material's prefix ID (material_...). The response is { "material": { ... } } with the same fields
as above. 403 if you may not see the material.
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
https://api-west4.w3dge.io/api/v1/materials/material_3kTMd9Lq
If building the classic set fails, status is failed and the message is in metadata.error:
"status": "failed",
"metadata": {
"error": "KTX2 generation failed for normal_map file \"iron_normal.png\" at resolution 1k"
}
A failed encode has status: "failed" on the variant and its message in the variant's metadata.error. An older
ready encode keeps being served.
List materials
- Endpoint:
GET /api/v1/materials
| Query parameter | Description |
|---|---|
scope | mine (default when signed in), organization, public (default when signed out) or all (yours, your organizations' and public ones, never unlisted) |
organization_id | With scope=organization, narrow to one of your organizations |
limit | 1–200, default 100 |
offset | Default 0 |
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
"https://api-west4.w3dge.io/api/v1/materials?scope=all&limit=50"
{
"materials": [
{ "prefix_id": "material_3kTMd9Lq", "name": "Polished Iron", "status": "ready", "visibility": "unlisted", ... }
],
"scope": "all",
"limit": 50,
"offset": 0
}
Results are ordered newest first. Signed out, only scope=public is allowed.
Update a material
- Endpoint:
PUT /api/v1/materials/:idorPATCH /api/v1/materials/:id
Takes the same fields as create. parameters is merged into the current values; a key set to null goes back to
its default. Replacing a source map re-encodes the textures it feeds. Changing y_flip or normal_convention
re-encodes them too. The files served now stay in place until the new ones are ready.
curl -X PATCH https://api-west4.w3dge.io/api/v1/materials/material_3kTMd9Lq \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"material": {"visibility": "public", "parameters": {"roughnessFactor": 0.4}}}'
Delete a material
- Endpoint:
DELETE /api/v1/materials/:id
Answers 200 OK with { "material": {} }. The stable links stop working at once. Files already handed out stay
loadable for a grace period.
curl -X DELETE -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
https://api-west4.w3dge.io/api/v1/materials/material_3kTMd9Lq
5. Encoded variants
A variant is one encoded .ktx2 file of one runtime texture. Every texture gets its default encode on create;
request more to serve different GPUs or budgets.
| Encoding | What it is | Options |
|---|---|---|
xuastc_ldr | Supercompressed ASTC. Native on ASTC GPUs (phones, Apple, Quest), transcoded to BC7 or ETC2 elsewhere | block (required): 4x4, 5x4, 5x5, 6x5, 6x6, 8x5, 8x6, 10x5, 10x6, 8x8, 10x8, 10x10, 12x10, 12x12 |
xubc7 | Supercompressed BC7. Native on desktop GPUs | quality, effort |
uastc_ldr | UASTC. Decodes with older Basis transcoders | quality, effort |
etc1s | ETC1S. Decodes with older Basis transcoders | quality, effort |
All encodings also take quality (1–100), effort (0–10, default 4), max_size (16–8192, longest side to scale the
source down to) and preferred (serve this one from the bare link).
Presets:
| Preset | Encodes |
|---|---|
default | XUASTC LDR, quality 85: 4x4 for base_color, orm and emissive, 6x6 for normal. 4x4 at quality 95 for displacement and sky |
full | The default encodes, plus UASTC for every texture, a second XUASTC block size for base_color and orm (6x6) and normal (4x4), and ETC1S (quality 85) and XUBC7 (quality 100) for base_color |
List variants
- Endpoint:
GET /api/v1/materials/:material_id/variants
Returns { "variants": [...] } with each variant's prefix_id (mvar_...), texture, encoding, block,
quality, effort, max_size, status, width, height, mip_levels, byte_size, psnr, preferred, url
and metadata (encoder, encode time, GPU bytes, error).
Request encodes
- Endpoint:
POST /api/v1/materials/:material_id/variants - Body:
{ "preset": "default" | "full", "max_size": 1024 }or{ "variants": [{ "texture", "encoding", "block", "quality", "effort", "max_size", "preferred" }] }
curl -X POST https://api-west4.w3dge.io/api/v1/materials/material_3kTMd9Lq/variants \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"variants": [{"texture": "normal", "encoding": "uastc_ldr"}]}'
Answers 202 Accepted with { "variants": [...queued], "errors": [...] }. Poll GET until each variant is ready
or failed. Requesting settings that already have a ready file encodes a new version; the current file is served
until the new one is ready. A preferred encode takes over only once it is ready. 503 means no encoder is available
on the server; 422 means the material has no source for the requested textures.
Delete a variant
- Endpoint:
DELETE /api/v1/materials/:material_id/variants/:id
Answers { "variant": {} }.
6. Stable links and manifest
Worlds and scripts load materials through stable links on the CDN host (for example https://cdn-west4.w3dge.io).
They work without signing in, whatever the material's visibility. Use the canonical ID (material_...).
| Link | Serves |
|---|---|
GET /m/<material_id>/<texture>.ktx2 | 302 to the current KTX2 file: the preferred ready variant, else a ready XUASTC variant, else the classic KTX2 file |
GET /m/<material_id>/<texture>.ktx2?encoding=&block=&quality= | 302 to a specific ready variant, or 404 |
GET /m/<material_id>/<texture>.webp?size= | 302 to the scaled image (size = 512, 1k, 2k, 4k, 8k; 2k first by default), else the uploaded source |
GET /m/<material_id>.json | The manifest |
<texture> is one of base_color, orm, normal, emissive, displacement or sky. Browsers re-check links
after 60 seconds; the CDN cache is purged whenever the material changes.
Manifest
{
"material": "material_3kTMd9Lq",
"name": "Polished Iron",
"version": 3,
"updated_at": "2026-11-02T10:15:00Z",
"format": "gltf-metallic-roughness",
"y_flip": false,
"parameters": { "baseColorFactor": [1, 1, 1, 1], "metallicFactor": 0.95, "roughnessFactor": 0.25, "...": "..." },
"textures": {
"base_color": {
"links": { "ktx2": "/m/material_3kTMd9Lq/base_color.ktx2", "webp": "/m/material_3kTMd9Lq/base_color.webp" },
"files": { "ktx2": "/rails/active_storage/blobs/proxy/...", "webp": "/rails/active_storage/blobs/proxy/..." },
"color_space": "srgb",
"layout": null,
"variants": [
{
"encoding": "xuastc_ldr", "block": "4x4", "quality": 85, "max_size": null,
"width": 2048, "height": 2048, "mip_levels": 12, "byte_size": 1843200, "psnr": 41.2,
"preferred": true, "gpu_bytes": { "astc": 5592400, "bc7": 5592400 },
"file": "/rails/active_storage/blobs/proxy/...",
"link": "/m/material_3kTMd9Lq/base_color.ktx2?block=4x4&encoding=xuastc_ldr&quality=85"
}
]
}
}
}
parametershas the defaults applied.color_spaceissrgb,linearornormal.layoutcomes from the material'smetadata.maps.<slot>.layout. When it hastilesandtile_m: [w, h], the texture tiles everyw×hmetres;loadMaterial()uses it for real-world scaling.- Links are relative to the CDN host. Resolve them against the manifest URL.