Skip to main content

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 .ktx2 files 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>.ktx2 and the /m/<material_id>.json manifest) 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 textureSource slotsthree.js propertyColor spaceNotes
base_colorcolor_map, opacity_mapmapsRGB (SRGBColorSpace)opacity_map (red channel) becomes the alpha channel
ormorm_map, or occlusion_map + roughness_map + metalness_mapaoMap (R), roughnessMap (G), metalnessMap (B)Linear (NoColorSpace)One texture for all three. A missing channel is white, so the factor alone sets the value
normalnormal_mapnormalMapLinear (NoColorSpace)Always OpenGL convention (Y+). A DirectX map is converted when normal_convention is directx
emissiveemissive_mapemissiveMapsRGB (SRGBColorSpace)
displacementdisplacement_mapdisplacementMapLinear (NoColorSpace)Needs a finely subdivided mesh
skysky_mapSky backgroundsRGBEquirectangular 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.

important

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.

ParameterType / rangeDefaultthree.js property
baseColorFactor[r, g, b, a], linear, 0–1[1, 1, 1, 1]color, opacity
metallicFactor0–11 with a metalness or ORM source, else 0metalness
roughnessFactor0–11roughness
normalScale-10–101normalScale
occlusionStrength0–11aoMapIntensity
emissiveFactor[r, g, b], linear, 0–1[1, 1, 1] with an emissive map, else [0, 0, 0]emissive
emissiveStrength0–10001emissiveIntensity
alphaModeOPAQUE, MASK, BLENDMASK with an opacity map, else OPAQUEtransparent (BLEND), alphaTest (MASK)
alphaCutoff0–10.5alphaTest when alphaMode is MASK
doubleSidedbooleanfalseside
displacementScale-100–100 (metres)0.02displacementScale
displacementBias-100–100 (metres)0displacementBias

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:

OptionEffect
physical: trueBuild a MeshPhysicalMaterial instead of a MeshStandardMaterial
displacement: falseSkip 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: falseKeep 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.

XUASTC and XUBC7 need a Basis Universal 2.50+ transcoder

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:

ValueWho can see it
private (default)The creator only
organizationMembers of its organization. Requires organization_id
unlistedAnyone who has its ID. Never listed
publicListed 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)
ParameterTypeRequiredDescription
material[name]StringYesName of the material
material[parameters]JSON object (a JSON string in multipart)NoglTF parameters, see Parameters
material[visibility]StringNoprivate (default), organization, unlisted or public
material[organization_id]StringNoOrganization (org_...) to file the material under. You must be a member
material[normal_convention]StringNoopengl (default) or directx. DirectX normal maps get their green channel inverted when encoded
material[y_flip]BooleanNoStore every encode bottom-up (for textures captured with flipY, such as canvas textures). Default false
material[metadata]JSON object (a JSON string in multipart)NoFree-form notes, such as how the maps were made. Up to 256 KB
material[color_map]FileNoBase color (sRGB)
material[opacity_map]FileNoAlpha (grey)
material[normal_map]FileNoTangent-space normal map
material[orm_map]FileNoPacked R = occlusion, G = roughness, B = metalness
material[occlusion_map]FileNoAmbient occlusion (grey)
material[roughness_map]FileNoRoughness (grey)
material[metalness_map]FileNoMetalness (grey)
material[emissive_map]FileNoEmission (sRGB)
material[displacement_map]FileNoSurface height (grey)
material[sky_map]FileNoEquirectangular sky panorama
material[height_map]FileNoTerrain elevation. Stored as lossless WebP at 256 to 4k, no KTX2
material[splat_map]FileNoTerrain 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).

Processing

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
Handling errors

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 parameterDescription
scopemine (default when signed in), organization, public (default when signed out) or all (yours, your organizations' and public ones, never unlisted)
organization_idWith scope=organization, narrow to one of your organizations
limit1–200, default 100
offsetDefault 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/:id or PATCH /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.

EncodingWhat it isOptions
xuastc_ldrSupercompressed ASTC. Native on ASTC GPUs (phones, Apple, Quest), transcoded to BC7 or ETC2 elsewhereblock (required): 4x4, 5x4, 5x5, 6x5, 6x6, 8x5, 8x6, 10x5, 10x6, 8x8, 10x8, 10x10, 12x10, 12x12
xubc7Supercompressed BC7. Native on desktop GPUsquality, effort
uastc_ldrUASTC. Decodes with older Basis transcodersquality, effort
etc1sETC1S. Decodes with older Basis transcodersquality, 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:

PresetEncodes
defaultXUASTC LDR, quality 85: 4x4 for base_color, orm and emissive, 6x6 for normal. 4x4 at quality 95 for displacement and sky
fullThe 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": {} }.


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_...).

LinkServes
GET /m/<material_id>/<texture>.ktx2302 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>.jsonThe 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"
}
]
}
}
}
  • parameters has the defaults applied.
  • color_space is srgb, linear or normal.
  • layout comes from the material's metadata.maps.<slot>.layout. When it has tiles and tile_m: [w, h], the texture tiles every w × h metres; loadMaterial() uses it for real-world scaling.
  • Links are relative to the CDN host. Resolve them against the manifest URL.