| name | threejs-textures |
| description | Three.js textures - texture types, UV mapping, environment maps, texture settings. Use when working with images, UV coordinates, cubemaps, HDR environments, or texture optimization. In HULLBREAKER this applies only to src/render/, src/ui/ and src/main.js, where canvas/data/procedural textures and render targets are the sanctioned path and external image, HDR, video or compressed-texture files are not. |
HULLBREAKER guardrails (read before using anything below)
This upstream reference assumes a bundled app that loads image files off disk.
This repo is neither. Verified against the tree at install time — re-verify with
node tools/pathcheck.mjs and node tools/assets/check.mjs, both green at
install time and both required to stay green. Line numbers and assertion counts
below drift as lanes merge; the function and symbol names next to them are the
durable anchors.
Where texture code may live. Only src/render/*.js, src/ui/*.js, and
src/main.js. Never src/pure/ or src/sim/. This is statically enforced:
tools/pathcheck.mjs (the guardLayer block, ~lines 95-138) strips comments
from src/config.js, every src/pure/*.js and every src/sim/*.js, then
matches /\b(THREE|document|window|renderer|scene|addEventListener|requestAnimationFrame|innerWidth|innerHeight|devicePixelRatio|performance)\b/
and calls process.exit(1) on a hit. Note the identifiers scene and
renderer are banned outright — not just THREE — so a "harmless" helper
parameter named scene in a sim file fails the gate. It also rejects any
import that crosses layers upward.
Texture code that reacts to game state crosses through the bridge. The
sim never hands a renderer object to anything; render modules register view
hooks via installView from src/sim/bridge.js. src/render/capsules.js:62
is the worked example (installView({ capsules: { spawned, removed, sync } })).
Canvas textures: already solved here — extend, don't reinvent.
src/render/capsules.js:16-32 is a working CanvasTexture factory:
document.createElement('canvas') at 64px, 2D draw, new THREE.CanvasTexture(cv),
memoized in letterTexCache keyed by text + '|' + bg. Its disposal contract
is deliberate and asymmetric: the shared BoxGeometry is "shared: never
disposed" (line 13), per-capsule materials are disposed in removed()
(lines 45-51), and the cached textures are intentionally never disposed
because other live capsules share them. Copying the upstream disposeMaterial()
helper (see "Dispose Textures") verbatim would dispose those shared cached
textures out from under every other capsule. Do not.
External image / HDR / video / compressed-texture files are NOT sanctioned.
The shipped game loads zero binary assets and boots with everything under
assets/ deleted. tools/assets/check.mjs enforces exactly this in
checkGameIndependence() (~line 170): it walks every .js/.mjs under src/
and errors on /^\s*import\s[^\n]*?['"]([^'"]*assets\/[^'"]*)['"]/, with the
message "the game must boot with every asset file missing (asset-artist
standing orders). Load through the render/ui layer at runtime with a fallback
instead." Today the tree is stricter than the rule — the check reports
"src/ contains no reference to assets/ at all", and assets/manifest.json
holds exactly one asset (capsule-letter-h) whose own notes record "nothing
loads it." So TextureLoader, CubeTextureLoader, RGBELoader, EXRLoader,
KTX2Loader, VideoTexture and file-backed texture atlases all change what
the product is. That needs an operator decision recorded in
docs/decisions.md before you write the loader, not after. Do not route
around it with a runtime fetch, a data-URI blob of a checked-in binary, or a
"temporary" flag. docs/decisions.md entry 8 opened an authoring lane
(codex-generated sprites under tools/assets/); it did not make the game load
them.
Two more hard rules bite here: no build step and no runtime dependencies
(root CLAUDE.md) — KTX2Loader.setTranscoderPath() wants basis transcoder
binaries that do not exist in this repo and are not in the import map.
Sanctioned without a new decision: CanvasTexture, DataTexture,
procedural/generated textures, WebGLRenderTarget / depth / MSAA targets,
WebGLCubeRenderTarget + CubeCamera, UV manipulation, texture settings on
any of the above.
Import specifiers. index.html (lines 46-53) maps exactly two:
three and three/addons/, both to three.js 0.170.0 on jsDelivr.
three/examples/jsm/... — what upstream writes — does not resolve. Every
addon import in the body below has been corrected to three/addons/....
Randomness. Root CLAUDE.md: randomness only via seeded src/pure/rng.js
(mulberry32). In src/pure/ and src/sim/ the identifier performance is
statically banned by the guard above; Math.random and Date.now are rule-
enforced, not regex-enforced, so review is the gate. Render-layer code may use
performance.now() (it already does — let last = performance.now() in
src/main.js, and advanceDeparting() in src/render/bullets.js), but a
procedural noise texture seeded from
Math.random() makes playtest screenshots irreproducible — tools/playtest's
--deterministic mode exists to remove exactly that variance. Seed from
mulberry32.
Colors in texture-drawing code. Today the palette is CONFIG.palette
(src/config.js:482, commented "grey-box: neutral + readability hints") and
docs/DESIGN.md caps it at ≤8 colors. A palette pass in lane T-010 is
in flight and unmerged at install time; it adds src/render/palette.js and a
pathcheck assertion that forbids raw 0xRRGGBB, CSS #hex, and rgb()/rgba()
literals in a tokenized file list that includes capsules.js — the very
file that draws canvas textures — with 0xffffff (the identity base color of
tint-colored materials) as the only exemption. That regex matches CSS strings,
so g.fillStyle = '#14181e' and gradient.addColorStop(0, '#...') are exactly
what it catches. Before writing any color into a canvas or DataTexture: check
whether src/render/palette.js exists, and pull tokens from it if it does.
If you generate an image through the dev pipeline (tools/assets/, opened
by docs/decisions.md entry 8), the asset gate is real and specific:
tools/assets/lib/palette.mjs defines roles as hue bands, ≤8 roles, with a
CIELCh chroma-12 neutral floor; tools/assets/check.mjs recomputes compliance
from pixels and fails on any off-palette hue. Power-of-two dimensions are a
repo rule, not just the upstream performance tip below: anything not marked
"gpu": false in assets/manifest.json must be power-of-two. Read
tools/assets/README.md first.
Detail dies at the shipped view scale. docs/decisions.md entry 7 made FAR
the default view (RIG ≈ 3.7% of screen height, per concept board 13). The one
manifest entry records the measured consequence: a 0.55-tile capsule is
~9.6px tall at FAR, "where the rivets and chamfer disappear entirely and
only the ink letter survives as a smudge." Before believing a texture works,
look at it at real size: node tools/assets/view.mjs <png> --tiles <n>.
A 2048px albedo for something 10px tall is wasted memory and a wasted lane.
Static-anatomy rule (docs/decisions.md entry 3): the creature's anatomy is
monumental and static during turns and transitions — it is revealed, never
assembled. Texture and UV animation is surface shading, not geometry, so it is
not banned — but do not use scrolling UVs, render-target tricks, or
CubeCamera reflection updates to make the anatomy read as articulating or
snapping into place during a transition. Only doors, access plates, vent
covers, shutters, traps, and Crown mechanisms may move.
Background and environment are a judged look, not a knob.
src/render/scene.js:15-16 binds scene.background and scene.fog to the same
CONFIG.palette.bg; that pairing is the depth cue at the FAR default, and
renderer.toneMapping = THREE.ACESFilmicToneMapping (line 11) already shapes how
any new texture reads. The scene runs 9 MeshBasicMaterial and 9
MeshStandardMaterial instances, so scene.environment would only reach the
Standard half. Any of this changes pixels, and machine gates never judge
fun — an unjudged look ships behind a query flag declared in src/mode.js
(off by default), with a packet in SPRINT.md's "Operator checkpoint queue"
(line 308): exact URL plus 3-5 questions. Never self-declare it good.
One live subtlety, flagged not fixed: in three.js 0.170 Texture.colorSpace
defaults to NoColorSpace, and src/render/capsules.js does not set it on its
CanvasTexture. So the canvas background does not match the same
CONFIG.palette hex used as a material color elsewhere (three.js color
management converts the latter). Setting tex.colorSpace = THREE.SRGBColorSpace
is arguably correct — and it changes shipped pixels, so it is an operator feel
question, not a drive-by fix.
Three.js Textures
Quick Start
import * as THREE from "three";
const loader = new THREE.TextureLoader();
const texture = loader.load("texture.jpg");
const material = new THREE.MeshStandardMaterial({
map: texture,
});
Texture Loading
Basic Loading
const loader = new THREE.TextureLoader();
loader.load(
"texture.jpg",
(texture) => console.log("Loaded"),
(progress) => console.log("Progress"),
(error) => console.error("Error"),
);
const texture = loader.load("texture.jpg");
material.map = texture;
Promise Wrapper
function loadTexture(url) {
return new Promise((resolve, reject) => {
new THREE.TextureLoader().load(url, resolve, undefined, reject);
});
}
const [colorMap, normalMap, roughnessMap] = await Promise.all([
loadTexture("color.jpg"),
loadTexture("normal.jpg"),
loadTexture("roughness.jpg"),
]);
Texture Configuration
Color Space
Critical for accurate color reproduction.
colorTexture.colorSpace = THREE.SRGBColorSpace;
Wrapping Modes
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
Repeat, Offset, Rotation
texture.repeat.set(4, 4);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.offset.set(0.5, 0.5);
texture.rotation = Math.PI / 4;
texture.center.set(0.5, 0.5);
Filtering
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.minFilter = THREE.NearestFilter;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.magFilter = THREE.NearestFilter;
texture.anisotropy = renderer.capabilities.getMaxAnisotropy();
Generate Mipmaps
texture.generateMipmaps = true;
texture.generateMipmaps = false;
texture.minFilter = THREE.LinearFilter;
Texture Types
Regular Texture
const texture = new THREE.Texture(image);
texture.needsUpdate = true;
Data Texture
Create texture from raw data.
const size = 256;
const data = new Uint8Array(size * size * 4);
for (let i = 0; i < size; i++) {
for (let j = 0; j < size; j++) {
const index = (i * size + j) * 4;
data[index] = i;
data[index + 1] = j;
data[index + 2] = 128;
data[index + 3] = 255;
}
}
const texture = new THREE.DataTexture(data, size, size);
texture.needsUpdate = true;
Canvas Texture
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "red";
ctx.fillRect(0, 0, 256, 256);
ctx.fillStyle = "white";
ctx.font = "48px Arial";
ctx.fillText("Hello", 50, 150);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
Video Texture
const video = document.createElement("video");
video.src = "video.mp4";
video.loop = true;
video.muted = true;
video.play();
const texture = new THREE.VideoTexture(video);
texture.colorSpace = THREE.SRGBColorSpace;
Compressed Textures
import { KTX2Loader } from "three/addons/loaders/KTX2Loader.js";
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath("path/to/basis/");
ktx2Loader.detectSupport(renderer);
ktx2Loader.load("texture.ktx2", (texture) => {
material.map = texture;
});
Cube Textures
For environment maps and skyboxes.
CubeTextureLoader
const loader = new THREE.CubeTextureLoader();
const cubeTexture = loader.load([
"px.jpg",
"nx.jpg",
"py.jpg",
"ny.jpg",
"pz.jpg",
"nz.jpg",
]);
scene.background = cubeTexture;
scene.environment = cubeTexture;
material.envMap = cubeTexture;
Equirectangular to Cubemap
import { RGBELoader } from "three/addons/loaders/RGBELoader.js";
const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();
new RGBELoader().load("environment.hdr", (texture) => {
const envMap = pmremGenerator.fromEquirectangular(texture).texture;
scene.environment = envMap;
scene.background = envMap;
texture.dispose();
pmremGenerator.dispose();
});
HDR Textures
RGBELoader
import { RGBELoader } from "three/addons/loaders/RGBELoader.js";
const loader = new RGBELoader();
loader.load("environment.hdr", (texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping;
scene.environment = texture;
scene.background = texture;
});
EXRLoader
import { EXRLoader } from "three/addons/loaders/EXRLoader.js";
const loader = new EXRLoader();
loader.load("environment.exr", (texture) => {
texture.mapping = THREE.EquirectangularReflectionMapping;
scene.environment = texture;
});
Background Options
scene.background = texture;
scene.backgroundBlurriness = 0.5;
scene.backgroundIntensity = 1.0;
scene.backgroundRotation.y = Math.PI;
Render Targets
Render to texture for effects.
const renderTarget = new THREE.WebGLRenderTarget(512, 512, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
});
renderer.setRenderTarget(renderTarget);
renderer.render(scene, camera);
renderer.setRenderTarget(null);
material.map = renderTarget.texture;
Depth Texture
const renderTarget = new THREE.WebGLRenderTarget(512, 512);
renderTarget.depthTexture = new THREE.DepthTexture(
512,
512,
THREE.UnsignedShortType,
);
const depthTexture = renderTarget.depthTexture;
Multi-Sample Render Target
const renderTarget = new THREE.WebGLRenderTarget(512, 512, {
samples: 4,
});
CubeCamera
Dynamic environment maps for reflections.
const cubeRenderTarget = new THREE.WebGLCubeRenderTarget(256, {
generateMipmaps: true,
minFilter: THREE.LinearMipmapLinearFilter,
});
const cubeCamera = new THREE.CubeCamera(0.1, 1000, cubeRenderTarget);
scene.add(cubeCamera);
reflectiveMaterial.envMap = cubeRenderTarget.texture;
function animate() {
reflectiveObject.visible = false;
cubeCamera.position.copy(reflectiveObject.position);
cubeCamera.update(renderer, scene);
reflectiveObject.visible = true;
}
UV Mapping
Accessing UVs
const uvs = geometry.attributes.uv;
const u = uvs.getX(vertexIndex);
const v = uvs.getY(vertexIndex);
uvs.setXY(vertexIndex, newU, newV);
uvs.needsUpdate = true;
Second UV Channel (for AO maps)
aoTexture.channel = 1;
geometry.setAttribute("uv1", geometry.attributes.uv);
const uv1 = new Float32Array(vertexCount * 2);
geometry.setAttribute("uv1", new THREE.BufferAttribute(uv1, 2));
UV Transform in Shader
const material = new THREE.ShaderMaterial({
uniforms: {
map: { value: texture },
uvOffset: { value: new THREE.Vector2(0, 0) },
uvScale: { value: new THREE.Vector2(1, 1) },
},
vertexShader: `
varying vec2 vUv;
uniform vec2 uvOffset;
uniform vec2 uvScale;
void main() {
vUv = uv * uvScale + uvOffset;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D map;
void main() {
gl_FragColor = texture2D(map, vUv);
}
`,
});
Texture Atlas
Multiple images in one texture.
const atlas = loader.load("atlas.png");
atlas.wrapS = THREE.ClampToEdgeWrapping;
atlas.wrapT = THREE.ClampToEdgeWrapping;
function selectSprite(row, col, gridSize = 2) {
atlas.offset.set(col / gridSize, 1 - (row + 1) / gridSize);
atlas.repeat.set(1 / gridSize, 1 / gridSize);
}
selectSprite(0, 0);
Material Texture Maps
PBR Texture Set
const material = new THREE.MeshStandardMaterial({
map: colorTexture,
normalMap: normalTexture,
normalScale: new THREE.Vector2(1, 1),
roughnessMap: roughnessTexture,
roughness: 1,
metalnessMap: metalnessTexture,
metalness: 1,
aoMap: aoTexture,
aoMapIntensity: 1,
emissiveMap: emissiveTexture,
emissive: 0xffffff,
emissiveIntensity: 1,
displacementMap: displacementTexture,
displacementScale: 0.1,
displacementBias: ,
: alphaTexture,
: ,
});
aoTexture. = ;
geometry.(, geometry..);
Normal Map Types
material.normalMapType = THREE.TangentSpaceNormalMap;
material.normalMapType = THREE.ObjectSpaceNormalMap;
Procedural Textures
Noise Texture
function generateNoiseTexture(size = 256) {
const data = new Uint8Array(size * size * 4);
for (let i = 0; i < size * size; i++) {
const value = Math.random() * 255;
data[i * 4] = value;
data[i * 4 + 1] = value;
data[i * 4 + 2] = value;
data[i * 4 + 3] = 255;
}
const texture = new THREE.DataTexture(data, size, size);
texture.needsUpdate = true;
return texture;
}
Gradient Texture
function generateGradientTexture(color1, color2, size = 256) {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = 1;
const ctx = canvas.getContext("2d");
const gradient = ctx.createLinearGradient(0, 0, size, 0);
gradient.addColorStop(0, color1);
gradient.addColorStop(1, color2);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size, 1);
return new THREE.CanvasTexture(canvas);
}
Texture Memory Management
Dispose Textures
texture.dispose();
function disposeMaterial(material) {
const maps = [
"map",
"normalMap",
"roughnessMap",
"metalnessMap",
"aoMap",
"emissiveMap",
"displacementMap",
"alphaMap",
"envMap",
"lightMap",
"bumpMap",
"specularMap",
];
maps.forEach((mapName) => {
if (material[mapName]) {
material[mapName].dispose();
}
});
material.dispose();
}
Texture Pooling
class TexturePool {
constructor() {
this.textures = new Map();
this.loader = new THREE.TextureLoader();
}
async get(url) {
if (this.textures.has(url)) {
return this.textures.get(url);
}
const texture = await new Promise((resolve, reject) => {
this.loader.load(url, resolve, undefined, reject);
});
this.textures.set(url, texture);
return texture;
}
dispose(url) {
const texture = this.textures.get(url);
if (texture) {
texture.();
..(url);
}
}
() {
..( t.());
..();
}
}
Performance Tips
- Use power-of-2 dimensions: 256, 512, 1024, 2048
— HULLBREAKER: this is a rule here, not advice.
tools/assets/check.mjs
fails any manifest entry not marked "gpu": false whose PNG/SVG dimensions
are not power-of-two.
- Compress textures: KTX2/Basis for web delivery — not available here (no
transcoder binaries, no build step).
- Use texture atlases: Reduce texture switches
- Enable mipmaps: For distant objects — relevant at the FAR default view.
- Limit texture size: 2048 usually sufficient for web — and at FAR a
0.55-tile prop is ~9.6px tall (
assets/manifest.json), so 2048 is almost
always far too much. Check with node tools/assets/view.mjs.
- Reuse textures: Same texture = better batching
console.log(renderer.info.memory.textures);
const maxSize = renderer.capabilities.maxTextureSize;
const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent);
const textureSize = isMobile ? 1024 : 2048;
See Also
threejs-materials - Applying textures to materials
threejs-loaders - Loading texture files
threejs-shaders - Custom texture sampling
HULLBREAKER references
src/render/capsules.js — the shipped CanvasTexture + cache + dispose pattern
src/render/scene.js — renderer, tone mapping, background/fog pairing
src/sim/bridge.js — the only sim-to-render crossing (installView)
tools/pathcheck.mjs — layer purity guard (guardLayer) + the assertion suite
tools/assets/check.mjs + tools/assets/README.md — palette, power-of-two,
and the game-independence rule
tools/assets/view.mjs — look at an asset at its real on-screen size
docs/decisions.md — entries 3 (static anatomy), 7 (FAR default), 8 (asset
lane). Verdicts are law; propose a new decision rather than re-litigating one.