TypeScript для Three.js: типізація сцени
Власні типи Three.js добре покривають поверхню API, але доменні типи симуляції — стан частинки, форма uniform-ів, форма завантажених ассетів — залежать від вас. Цей туторіал охоплює патерни TypeScript, які ловлять реальні баги у WebGL-коді: типізовані вузли графа сцени, generic-атрибути буфера, дискримінантні об'єднання для стану тіла, і строго типізовані шейдерні uniform-и.
1. Налаштування проєкту і строгий tsconfig
Починаючи з r150, Three.js постачає власні файли декларацій
TypeScript — @types/three застарілий для поточних
версій, хоча ще може траплятись зафіксованим у старіших
проєктах.
npm install three
npm install -D typescript vite
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true
}
}
noUncheckedIndexedAccess важливий тут:
код симуляції постійно індексує типізовані масиви
(positions[i * 3]) — без цього прапорця TypeScript
вважає, що індекс завжди існує, і приховує реальні баги виходу
за межі масиву, які проявляються як тихе поширення
NaN у рендері.
2. Типізація графа сцени
THREE.Mesh є generic-типом за геометрією і
матеріалом починаючи з r152 — використовуйте generics, щоб
mesh.geometry і mesh.material
зберігали конкретний тип замість звуження до
BufferGeometry | Material:
import * as THREE from 'three';
// Конкретні типи геометрії + матеріалу проходять через .geometry / .material
const geometry = new THREE.SphereGeometry(1, 32, 32);
const material = new THREE.MeshStandardMaterial({ color: 0x60a5fa });
const mesh: THREE.Mesh<THREE.SphereGeometry, THREE.MeshStandardMaterial>
= new THREE.Mesh(geometry, material);
mesh.material.roughness = 0.4; // типізовано — каст не потрібен
// Власний userData потребує свого інтерфейсу — Three.js типізує його як `any`
interface SimBodyUserData {
bodyId: number;
mass: number;
}
function getBodyId(obj: THREE.Object3D): number | undefined {
const data = obj.userData as Partial<SimBodyUserData>;
return data.bodyId;
}
object.children типізовано як
Object3D[], тож
scene.traverse(obj => { ... }) дає вам звичайний
Object3D — звужуйте через
obj instanceof THREE.Mesh перед тим, як торкатись
.geometry чи .material.
3. Типізовані атрибути BufferGeometry
BufferAttribute є generic-типом за своїм базовим
типізованим масивом. Читання його з неправильно припущеним типом
— поширене джерело тихих багів точності (наприклад, трактування
атрибута Float32Array, ніби він містить цілі
числа).
function getPositionAttribute(
geometry: THREE.BufferGeometry,
): THREE.BufferAttribute {
const attr = geometry.getAttribute('position');
if (!(attr instanceof THREE.BufferAttribute)) {
throw new Error('position attribute missing or interleaved');
}
return attr;
}
function displaceVertices(geometry: THREE.BufferGeometry, dt: number): void {
const pos = getPositionAttribute(geometry);
const arr = pos.array as Float32Array; // відоме розташування для цієї геометрії
for (let i = 0; i < pos.count; i++) {
const y = arr[i * 3 + 1];
arr[i * 3 + 1] = y + Math.sin(dt) * 0.01;
}
pos.needsUpdate = true;
}
4. Дискримінантні об'єднання для стану симуляції
Більшість симуляцій змішують кілька «видів» тіл із різними
полями — моделюйте це як помічене об'єднання
(tagged union) за полем kind, а не одним
інтерфейсом-мішком з опціональних полів. Компілятор тоді змусить
вас явно обробити кожен вид.
type SimBody =
| { kind: 'particle'; position: THREE.Vector3; velocity: THREE.Vector3; mass: number }
| { kind: 'rigid'; position: THREE.Vector3; quaternion: THREE.Quaternion; inertia: THREE.Matrix3 }
| { kind: 'anchor'; position: THREE.Vector3 }; // статичне, ніколи не інтегрується
function integrate(body: SimBody, dt: number): void {
switch (body.kind) {
case 'particle':
body.position.addScaledVector(body.velocity, dt);
break;
case 'rigid':
integrateRigidBody(body, dt); // body звужено до варіанту 'rigid'
break;
case 'anchor':
break; // статичне — робити нічого не треба
default: {
const _exhaustive: never = body; // помилка компіляції, якщо додано новий вид без обробки
throw new Error(`Unhandled body kind`);
}
}
}
never:
додайте четвертий варіант SimBody пізніше, і ця
гілка default не скомпілюється, доки ви не додасте
відповідний case — дешевий спосіб гарантувати, що
нові типи тіл не проваляться мовчки неінтегрованими.
5. Типізовані шейдерні uniform-и
ShaderMaterial типізує своє поле
uniforms як вільний
{ [name: string]: IUniform } — визначте власну
форму uniform-ів один раз і повторно використовуйте, тож
помилка в імені uniform-а стане помилкою компіляції, а не тихим
undefined у GLSL.
interface WaveUniforms {
uTime: THREE.IUniform<number>;
uAmplitude: THREE.IUniform<number>;
uColorA: THREE.IUniform<THREE.Color>;
uColorB: THREE.IUniform<THREE.Color>;
}
const uniforms: WaveUniforms = {
uTime: { value: 0 },
uAmplitude: { value: 0.3 },
uColorA: { value: new THREE.Color(0x1e293b) },
uColorB: { value: new THREE.Color(0x60a5fa) },
};
const material = new THREE.ShaderMaterial({
uniforms, // структурно сумісне з Record<string, IUniform>
vertexShader,
fragmentShader,
});
function updateWave(elapsed: number): void {
uniforms.uTime.value = elapsed; // захищено від одруку: uniforms.uTme не скомпілюється
}
6. Типізація GLTF та завантажувачів ресурсів
GLTFLoader.loadAsync() повертає generic-об'єкт
GLTF — його scene має тип
THREE.Group, тож будь-який меш, який ви очікуєте
всередині, все одно потребує рантайм-перевірки типу, а не
просто каста.
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
interface LoadedRobotAsset {
scene: THREE.Group;
armMesh: THREE.Mesh;
clips: THREE.AnimationClip[];
}
async function loadRobot(url: string): Promise<LoadedRobotAsset> {
const loader = new GLTFLoader();
const gltf = await loader.loadAsync(url);
const armMesh = gltf.scene.getObjectByName('Arm');
if (!(armMesh instanceof THREE.Mesh)) {
throw new Error('Expected "Arm" node to be a Mesh');
}
return { scene: gltf.scene, armMesh, clips: gltf.animations };
}
LoadedRobotAsset, перетворює зламаний/перейменований
експорт з вашого 3D-інструменту на негайну, читабельну помилку
замість Cannot read properties of undefined
глибоко в циклі рендеру.
7. Типові помилки типізації
-
Змішування
@types/threeз вбудованими типами r150+: встановлення обох одразу дає дублюючі/конфліктуючі декларації — видаліть@types/three, щойно ваша версіяthreeпостачає власні типи (r152+). -
Каст
Object3DуMeshбез перевірки: неперевіренийas THREE.Meshкомпілюється без проблем, а потім кидає виняток під час виконання на першій же дитині, що не є мешем — надавайте перевагу звуженню черезinstanceofусюди, де ви обходите сцену. -
Вважати виклики
dispose()повними: TypeScript спокійно дозволяє викликатиgeometry.dispose()без попередження, що матеріали/текстури, прикріплені до меша, потребують окремого звільнення — система типів не зловить витік пам'яті GPU, лише рантайм-профайлер. -
Надто generic допоміжні функції: функція,
типізована як
function setUniform<T>(m: THREE.ShaderMaterial, k: string, v: T), зводить нанівець сенс — надавайте перевагу конкретному інтерфейсу у стиліWaveUniformsз розділу 5, щоб недійсні імена uniform-ів ловились у місці виклику. -
Методи Vector/Quaternion мутують на місці:
це не зовсім баг типізації, але TypeScript не завадить вам
написати
const next = body.position.add(velocity), очікуючи незмінний результат —.add()мутуєbody.positionі повертаєthis; використовуйте.clone().add(...), коли потрібен новий вектор.
Часті запитання
Чого я навчуся в цьому уроці?
Додайте TypeScript у проєкт на Three.js: типізація ієрархій Object3D, загальні (generic) атрибути BufferGeometry, дискримінантні об'єднання для стану симуляції, типізовані uniform-и для ShaderMaterial.
Які теми розглядаються в цьому уроці?
Цей урок охоплює такі теми: Налаштування проєкту і строгий tsconfig, Типізація графа сцени, Типізовані атрибути BufferGeometry, Дискримінантні об'єднання для стану симуляції, Типізовані шейдерні uniform-и, Типізація GLTF та завантажувачів ресурсів, Типові помилки типізації.
Скільки часу займає цей урок?
Цей урок займає приблизно 20 хв.
Які попередні знання потрібні?
Це урок рівня «Середній рівень» — окрема попередня підготовка, крім базового JavaScript, не потрібна.