Туторіал · TypeScript · Three.js · Інструменти
📅 Липень 2026 ⏱ ≈ 20 хв 🎯 Середній рівень

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. Типові помилки типізації

Часті запитання

Чого я навчуся в цьому уроці?

Додайте TypeScript у проєкт на Three.js: типізація ієрархій Object3D, загальні (generic) атрибути BufferGeometry, дискримінантні об'єднання для стану симуляції, типізовані uniform-и для ShaderMaterial.

Які теми розглядаються в цьому уроці?

Цей урок охоплює такі теми: Налаштування проєкту і строгий tsconfig, Типізація графа сцени, Типізовані атрибути BufferGeometry, Дискримінантні об'єднання для стану симуляції, Типізовані шейдерні uniform-и, Типізація GLTF та завантажувачів ресурсів, Типові помилки типізації.

Скільки часу займає цей урок?

Цей урок займає приблизно 20 хв.

Які попередні знання потрібні?

Це урок рівня «Середній рівень» — окрема попередня підготовка, крім базового JavaScript, не потрібна.