three.js glTF textures black on iOS? Add up what the textures take on a phone

Why a glTF model's textures can draw black, or the page crash, on iPhones and not on a desktop, what the three.js threads found, and how a coding agent reads each texture's pixel size with the paprika3d plugin's model_audit tool, the texture megabytes and pixel ratio with perf against its mobile budget, and caps both.

What the threads found

A forum thread and three.js issue #22652, opened under the same title in October 2021, describe the same thing: a model that draws correctly on a desktop and in Android browsers shows one or more textures black on an iPhone, sometimes not until the second or third load, and some pages crash with Safari's "a problem repeatedly occurred". Most reports came after the move to iOS 15.

Two causes are named there, both by the people who maintain three.js:

The memory side is what a page controls. A texture is decoded before it reaches the GPU, so its cost is its pixels, not its file: width × height × 4 bytes, and a third more for the mipmaps three.js makes. A normal map and a roughness map cost as much as the colour map. A file can be small on disk and still ask a phone for hundreds of megabytes, and the canvas's own drawing buffer grows with the phone's pixel ratio on top of that.

paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome. model_audit reads a .glb or .gltf without loading it and lists each texture's pixel size and format, with the shrink call that caps them. open takes a phone's viewport with size and dpr. perf with budget: "mobile" reports the textures' megabytes on the GPU, the largest texture and the pixel ratio. The harness never runs on iOS or in Safari, and its mobile budget is a desktop proxy: the limits are a phone's, the machine is not. So it does not reproduce the black textures; it measures the memory the threads point to.

Install

paprika3d needs Google Chrome and Node 20.11 or later, and the Claude Code plugin needs Claude Code 2.1.118 or later.

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
npm install three

The model and the page

models/lamp.glb is a table lamp built for this guide with gltf-transform, shaped like an export from a texturing tool: two materials, shade and ceramic, each with a base colour map (JPEG), a normal map (PNG) and a metallic-roughness map (PNG), all six at 4096 × 4096. The patterns are smooth and procedural, so the file is small; a photographed texture would make the file larger and leave the memory the same. The page loads it the common way, with the renderer at the device's full pixel ratio:

<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';

const MODEL = 'models/lamp.glb';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(devicePixelRatio);
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.environment = new THREE.PMREMGenerator(renderer).fromScene(new RoomEnvironment(), 0.04).texture;
const camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 0.05, 20);
camera.position.set(0.9, 0.8, 1.6);
camera.lookAt(0, 0.45, 0);
new GLTFLoader().load(MODEL, (gltf) => scene.add(gltf.scene));
renderer.setAnimationLoop(() => renderer.render(scene, camera));
</script>

The tool calls, in order

These replies were captured on 2026-10-28 on a Linux server with no GPU, so Chrome drew in software (SwiftShader) on a busy host. The texture megabytes, sizes and pixel ratios do not depend on that; the frame and load times do, and this guide reads nothing from them. Run folder paths are shortened to <project>, and replies are cut to the lines this guide discusses, with nothing reworded.

1. Read the textures from the file. No page needs to be open:

model_audit {"cwd": "<project>", "path": "models/lamp.glb"}

models/lamp.glb · 1.16 MB · 384 triangles · 2 materials · textures: 4096×4096 jpeg, 4096×4096 png, 4096×4096 png, 4096×4096 jpeg, 4096×4096 png, 4096×4096 png · Draco no, meshopt no, KTX2 no
site: fails, 4096×4096 texture over 2048; 4096×4096 texture over 2048; 4096×4096 texture over 2048; 4096×4096 texture over 2048; 4096×4096 texture over 2048; 4096×4096 texture over 2048
Fix: `model_audit` { path: "models/lamp.glb", shrink: { textures: 2048 } } writes models/lamp-opt.glb with each texture at most 2048 px (WebP; textures: false drops them); then `look` compare against a look saved before (save).

At 1.16 MB the file is well inside the site target's asset budget, and the verdict fails it on the textures alone: six at 4096 px, twice the 2048 px the target allows on a side.

2. Open it at a phone's size. 390 × 844 CSS pixels at a pixel ratio of 3 is the viewport of an iPhone 12, 13 or 14:

open {"url": "index.html", "cwd": "<project>", "size": [390, 844], "dpr": 3}

Opened http://127.0.0.1:40079/index.html at 390 × 844, DPR 3 in 7.9 s, paused at frame 0.
Loaded: lamp.glb 1.2 MB

look {}

Canvas: 1170x2532 buffer for 390x844 CSS at DPR 3 (renderer pixel ratio 3, no cap); resize handler ran.
On screen (3):
shade 28% [0, 100, 389, 376]
base 25% [43, 483, 346, 793]
stem 1.2% [181, 377, 208, 515] 0.3 × 0.04 units

3. Measure it against the mobile budget.

perf {"budget": "mobile"}

Only the limits are a phone's: the frame ran on this host's GPU, which is faster than a phone's, so GPU work (shadows, post-processing, a high pixel ratio) costs more there than this says.
Assets are the files this load fetched, largest lamp.glb 1.16 MB.
Textures 515 MB on the GPU (largest 4096 px), 7 shader programs, pixel ratio 3, heap 12.83 MB, 4 long tasks, 0 frames over 3× the median (GC-like pauses); noise between two samples 312.4 ms.

A 1.16 MB file asks for 515 MB of texture memory. The sum, in the same megabytes perf uses (2 to the power 20 bytes):

one 4096 × 4096 texture   4096 × 4096 × 4 bytes = 64 MB, plus a third for mipmaps = 85.3 MB
six of them               512 MB; the rest of the 515 MB is the room environment's map
at 1024 px                1024 × 1024 × 4 bytes, plus a third = 5.3 MB each, 32 MB for six
the drawing buffer        1170 × 2532 × 4 bytes = 11.3 MB for its colour alone, at pixel ratio 3

The mobile budget in src/budgets.ts holds a texture to 1024 px and the pixel ratio to 2, and the source calls its mobile entry a rough proxy. This page is over both. The verdict at the top of the reply, cut here, names the frame time, which is over on this software renderer and no signal; read the texture line against those two limits yourself.

4. Cap the textures at 1024 px. The reduce GLB file size guide walks through shrink and the look comparison that checks the picture after it; here it is one call:

model_audit {"cwd": "<project>", "path": "models/lamp.glb", "shrink": {"textures": 1024, "out": "models/lamp-1k.glb"}}

Before: models/lamp.glb · 1.16 MB · 384 triangles · 2 materials · textures: 4096×4096 jpeg, 4096×4096 png, 4096×4096 png, 4096×4096 jpeg, 4096×4096 png, 4096×4096 png · Draco no, meshopt no, KTX2 no
After: models/lamp-1k.glb · 60 KB · 384 triangles · 2 materials · textures: 1024×1024 webp, 1024×1024 webp, 1024×1024 webp, 1024×1024 webp, 1024×1024 webp, 1024×1024 webp · Draco no, meshopt no, KTX2 no
site: passes

On this phone viewport the shade covers 390 by about 280 CSS pixels, so a 1024 px texture still has more pixels than the screen shows of it.

5. Cap the pixel ratio at 2, and measure again. Two edits to the page: const MODEL = 'models/lamp-1k.glb' and renderer.setPixelRatio(Math.min(devicePixelRatio, 2)).

open {"url": "index.html", "cwd": "<project>", "size": [390, 844], "dpr": 3}

Loaded: lamp-1k.glb 0.1 MB

look {}

Canvas: 780x1688 buffer for 390x844 CSS at DPR 3 (renderer pixel ratio 2, capped below DPR 3); resize handler ran.

perf {"budget": "mobile"}

Textures 35 MB on the GPU (largest 1024 px), 7 shader programs, pixel ratio 2, heap 8.71 MB, 9 long tasks, 0 frames over 3× the median (GC-like pauses); noise between two samples 8.1 ms.

eval {"code": "const b = renderer.getDrawingBufferSize(new THREE.Vector2());\nreturn { buffer: [b.x, b.y], colourMB: +(b.x * b.y * 4 / 2 ** 20).toFixed(1), pixelRatio: renderer.getPixelRatio() }"}

{"buffer":[780,1688],"colourMB":5,"pixelRatio":2}

Texture memory went from 515 MB to 35 MB, and the colour buffer from 11.3 MB to 5 MB. Six 1024 px textures are 32 MB; the rest is again the environment map.

The same sum in the page

perf measures a page in the harness. On a real phone, the page can add up its own textures once the model has loaded, and log the total or pick a smaller model. This block does that for every texture in a scene, with the figures the iOS thread reported as comments:

// verify: inline
import * as THREE from 'three';

// What a scene's textures and the canvas ask of a phone's GPU, in MB of 2 ** 20 bytes.
// Figures reported in three.js issue #22652 (iOS 15, 2021); estimates for those devices, not published limits:
//   donmccurdy: textures went dark past about 250 MB of decoded images on an iPhone 11 Pro
//   hybridherbst: around 120 MB on the devices they tried, and failures from 3 MB models with five 2048 px textures
function phoneMemory(renderer, scene) {
  const textures = new Set();
  scene.traverse((o) => {
    for (const m of [o.material].flat()) for (const v of Object.values(m ?? {})) if (v?.isTexture) textures.add(v);
  });
  let bytes = 0, largest = 0;
  for (const t of textures) {
    const { width = 0, height = 0 } = t.image ?? {};
    bytes += width * height * 4 * (t.generateMipmaps ? 4 / 3 : 1);
    largest = Math.max(largest, width, height);
  }
  const buffer = renderer.getDrawingBufferSize(new THREE.Vector2());
  const mb = (b) => +(b / 2 ** 20).toFixed(1);
  return { textures: textures.size, largest, textureMB: mb(bytes), colourBufferMB: mb(buffer.x * buffer.y * 4) };
}

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));   // a phone's 3 costs 2.25 times the pixels of 2
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xe9e4dc);
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 50);
camera.position.set(1.8, 1.4, 2.6);
camera.lookAt(0, 0, 0);
scene.add(new THREE.HemisphereLight(0xffffff, 0x8899aa, 3));

// Three 1024 px canvas textures stand in for a model's colour, normal and roughness maps.
function canvasTexture(draw) {
  const canvas = Object.assign(document.createElement('canvas'), { width: 1024, height: 1024 });
  draw(canvas.getContext('2d'));
  return new THREE.CanvasTexture(canvas);
}
const map = canvasTexture((g) => { g.fillStyle = '#3c7f8f'; g.fillRect(0, 0, 1024, 1024); g.fillStyle = '#e6d3a8'; g.fillRect(0, 384, 1024, 256); });
map.colorSpace = THREE.SRGBColorSpace;
const normalMap = canvasTexture((g) => { g.fillStyle = '#8080ff'; g.fillRect(0, 0, 1024, 1024); });
const roughnessMap = canvasTexture((g) => { g.fillStyle = '#909090'; g.fillRect(0, 0, 1024, 1024); });
const vase = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.7, 1.4, 48), new THREE.MeshStandardMaterial({ map, normalMap, roughnessMap }));
vase.name = 'vase';
scene.add(vase);

renderer.setAnimationLoop(() => renderer.render(scene, camera));
console.log(JSON.stringify(phoneMemory(renderer, scene)));   // {"textures":3,"largest":1024,"textureMB":16,...}

What paprika3d does not do

Pitfalls

Without paprika3d

npx @gltf-transform/cli inspect lamp.glb prints a table of the textures with their resolution, and gltf.report shows the same in a browser tab with an estimate of the GPU memory they take. npx @gltf-transform/cli resize lamp.glb lamp-1k.glb --width 1024 --height 1024 caps them, and gltfpack -tc or gltf-transform's uastc and etc1s commands write KTX2. In three.js, renderer.capabilities.maxTextureSize gives the device's largest texture side, and logging the phoneMemory sum above on the phone itself, through Safari's Web Inspector connected over USB, shows what the page asks of that device.