Angular-first 3D engine powered by Three.js. Use this skill when creating, modifying, or debugging 3D scenes, physics simulations, GLTF loading, post-processing effects, or any code that uses the `triangular-engine` library.
This skill provides comprehensive guidance for working with the triangular-engine Angular library — an Angular-first 3D engine powered by Three.js with optional Rapier/Jolt physics.
Use this skill whenever:
npm i triangular-engine three three-mesh-bvh dexie
Optional physics:
# Rapier (recommended)
npm i @dimforge/rapier3d-compat
# OR Jolt
npm i jolt-physics
| Package | Version |
|---|---|
@angular/common | ^20.3.3 |
@angular/core | ^20.3.3 |
three | ^0.181.0 |
dexie | ^4.2.1 |
angular.json AssetsAdd the following to your project's angular.json → architect.build.options.assets:
{
"glob": "**/*",
"input": "node_modules/triangular-engine/assets",
"output": "triangular-engine"
}
For DRACO-compressed GLTF models, also add:
{
"glob": "**/*",
"input": "node_modules/three/examples/jsm/libs/draco/",
"output": "draco/"
}
npm linkWhen working with a locally-built version of the library:
# In the triangular-engine workspace
npm run link
# In your app
npm link triangular-engine
Critical: You MUST set "preserveSymlinks": true in your app's angular.json → architect.build.options to avoid NullInjectorError issues (e.g. _HighContrastModeDetector token failures).
Also add to your app's tsconfig.json:
{
"compilerOptions": {
"paths": {
"triangular-engine": ["node_modules/triangular-engine"]
}
}
}
Every component that hosts a <scene> MUST provide EngineService. Use the static helper:
import { Component } from "@angular/core";
import { EngineModule, EngineService } from "triangular-engine";
@Component({
selector: "app-my-scene",
imports: [EngineModule],
providers: EngineService.provide({ showFPS: true }),
template: `
<scene>
<!-- 3D content here -->
</scene>
`,
})
export class MySceneComponent {}
EngineService.provide({
showFPS: true, // Show FPS counter
transparent: true, // Transparent canvas background
preferredRenderer: "webgl", // 'webgl' | 'webgpu'
});
<scene> per component/viewport. Do NOT nest multiple <scene> elements.EngineModule — it re-exports all engine components.EngineService at the component level, NOT at the root/module level.All components are standalone. Import EngineModule for convenience.
| Selector | Description |
|---|---|
scene | Scene host — contains the canvas and render loop |
group | Logical container (Three.js Group) |
mesh | Renderable mesh |
points | Point cloud |
sprite | Screen-aligned sprite |
primitive | Low-level Three.js object wrapper |
gridHelper | Grid visualization |
arrowHelper | Arrow visualization |
| Selector | Key Inputs |
|---|---|
camera | position, lookAt, isActive, far |
orbitControls | target, cameraPosition, isActive, follow, moveBy |
| Selector | Params |
|---|---|
boxGeometry | [params]="[width, height, depth]" |
sphereGeometry | [params]="{ radius, widthSegments, heightSegments }" |
planeGeometry | [params]="[width, height]" |
capsuleGeometry | — |
bufferGeometry | Custom geometry with bufferAttribute children |
| Selector | Key Inputs |
|---|---|
meshStandardMaterial | params (color, emissive, roughness, metalness), map (texture path) |
meshNormalMaterial | — |
meshBasicMaterial | params |
shaderMaterial | Custom GLSL shaders |
rawShaderMaterial | Custom raw GLSL shaders |
pointsMaterial | For use with <points> |
spriteMaterial | For use with <sprite> |
| Selector | Key Inputs |
|---|---|
ambientLight | intensity, color |
directionalLight | position, castShadow, color, intensity |
pointLight | position, color, intensity |
| Selector | Key Inputs |
|---|---|
effect-composer | Wraps pass components |
unrealBloomPass | strength, radius, threshold |
glitchPass | goWild |
smaaPass | — |
outputPass | — (should be last) |
shaderPass | Custom shader pass |
| Selector | Key Inputs |
|---|---|
gltf | gltfPath, enableBVH, cachePath |
| Selector | Description |
|---|---|
css2d | Overlay HTML in 3D (screen-aligned) |
css3d | Overlay HTML in 3D (perspective-transformed) |
| Selector | Key Inputs |
|---|---|
physics | gravity, debug, paused |
rigidBody | rigidBodyType (0=Dynamic, 1=Fixed, 2=KinematicPosition, 3=KinematicVelocity), position, velocity, mass, id |
cuboidCollider | halfExtents |
ballCollider | radius |
capsuleCollider | — |
cylinderCollider | — |
coneCollider | — |
fixedJoint | anchor1, frame1, anchor2, frame2 |
sphericalJoint | anchor1, anchor2 |
springJoint | anchor1, anchor2, axis, stiffness, damping, target |
instancedRigidBody | maxCount |
| Selector | Key Inputs |
|---|---|
jolt-physics | gravity, debug |
jolt-rigid-body | motionType (0=Static, 1=Kinematic, 2=Dynamic), position, rotation, velocity, id |
jolt-box-shape | params [w, h, d] |
jolt-sphere-shape | params [radius] |
jolt-capsule-shape | params [halfHeight, radius] |
jolt-cylinder-shape | params [halfHeight, radius] |
jolt-hull-shape | Convex hull from points |
jolt-mesh-shape | Triangle mesh (static only) |
| Selector | Description |
|---|---|
skyBox | Sky environment |
ocean | Ocean surface |
performanceMonitor | FPS/performance overlay |
sceneTree | Scene hierarchy viewer |
engine-ui | UI shell with slots |
engine-stats | Stats overlay |
[engineSlot] | Slot directive for engine UI |
[raycast] | Raycast directive |
All 3D node components extend Object3DComponent and accept:
| Input | Type | Description |
|---|---|---|
position | [x, y, z] | World/local position |
rotation | [x, y, z] | Euler rotation (radians) |
scale | number | [x, y, z] | Uniform or per-axis scale |
name | string | Name for the Three.js object |
castShadow | boolean | Whether to cast shadows |
receiveShadow | boolean | Whether to receive shadows |
Core rendering and control service. Inject it in components that host <scene>.
private readonly engineService = inject(EngineService);
Key APIs:
scene — The Three.js Scene instancerenderer — The WebGLRenderer or WebGPURenderercamera$ — BehaviorSubject<Camera> for the active cameratick$ — BehaviorSubject<number> emitting delta time each frameelapsedTime$ — BehaviorSubject<number> total elapsed timeswitchCamera(camera) — Switch active camerarequestSingleRender() — Trigger a single render framesetFPSLimit(fps) — Limit rendering FPScomposer — EffectComposer | undefined for post-processingkeydown$, keyup$, mousemove$, mouseup$, mousedown$, click$, wheel$, contextmenu$Rapier physics world management.
world$, beforeStep$, stepped$getRigidBodyById(id) — Look up a rigid body by its string IDsetSimulatePhysics(paused) — Pause/resume physicssetDebugState(debug) — Toggle debug visualizationAsset loading with caching.
loadAndCacheGltf(path, cachePath?, force?) — Load and cache GLTFloadAndCacheTexture(path) — Load and cache texture/draco/@Component({
selector: "app-demo",
imports: [EngineModule],
providers: EngineService.provide({ showFPS: true }),
template: `
<scene>
<camera [position]="[4, 3, 6]" [lookAt]="[0, 0, 0]" />
<directionalLight [position]="[3, 5, 2]" />
<mesh>
<boxGeometry [params]="[2, 2, 2]" />
<meshStandardMaterial />
</mesh>
</scene>
`,
})
export class DemoComponent {}
<mesh [position]="[0, 1, 0]" [castShadow]="true">
<boxGeometry [params]="[1, 1, 1]" />
<meshStandardMaterial [params]="{ color: '#88c' }" />
</mesh>
<meshStandardMaterial [map]="'assets/textures/wood.jpg'" />
<gltf [gltfPath]="'assets/models/scene.glb'" [enableBVH]="true" />
<scene>
<camera [position]="[0, 0, 6]" [lookAt]="[0, 0, 0]" />
<!-- scene content -->
<effect-composer>
<unrealBloomPass [strength]="1.2" [radius]="0.4" [threshold]="0.85" />
<glitchPass [goWild]="false" />
<smaaPass />
<outputPass />
</effect-composer>
</scene>
Note:
outputPassshould always be the last pass. Post-processing requirespreferredRenderer: 'webgl'.
<scene>
<camera [position]="[4, 3, 6]" [lookAt]="[0, 0, 0]" />
<directionalLight [position]="[3, 5, 2]" />
<physics [gravity]="[0, -9.81, 0]" [debug]="false">
<!-- Ground (Fixed) -->
<rigidBody [rigidBodyType]="1">
<cuboidCollider [halfExtents]="[50, 0.5, 50]" />
<mesh [position]="[0, -0.5, 0]">
<boxGeometry [params]="[100, 1, 100]" />
<meshStandardMaterial [params]="{ color: '#666' }" />
</mesh>
</rigidBody>
<!-- Falling Ball (Dynamic) -->
<rigidBody [rigidBodyType]="0" [position]="[0, 4, 0]">
<ballCollider [radius]="0.5" />
<mesh>
<sphereGeometry [params]="{ radius: 0.5, widthSegments: 32, heightSegments: 16 }" />
<meshNormalMaterial />
</mesh>
</rigidBody>
</physics>
</scene>
export class AnimatedSceneComponent implements OnInit {
private readonly engineService = inject(EngineService);
private readonly destroyRef = inject(DestroyRef);
readonly rotation = signal<[number, number, number]>([0, 0, 0]);
ngOnInit(): void {
this.engineService.elapsedTime$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
const t = this.engineService.elapsedTime$.value;
this.rotation.set([t * 0.3, t * 0.5, 0]);
});
}
}
<scene>
<orbitControls [cameraPosition]="[0, 5, 10]" [target]="[0, 0, 0]" [isActive]="true" />
<!-- scene content -->
</scene>
NullInjectorError: No provider for _EngineServiceCause: Missing EngineService.provide(...) in the component hosting <scene>.
Fix: Add providers: EngineService.provide({ ... }) to your @Component.
npm link (e.g. _HighContrastModeDetector)Cause: Symlink resolution creates duplicate Angular instances.
Fix:
"preserveSymlinks": true in angular.json → build options"paths": { "triangular-engine": ["node_modules/triangular-engine"] } to tsconfig.jsonCause: Post-processing (EffectComposer) requires WebGL renderer.
Fix: Ensure preferredRenderer: 'webgl' in your engine options (this is the default).
Cause: Draco decoder files not served.
Fix: Add the Draco asset glob to angular.json (see Setup section above).
instancedRigidBody or InstancedMesh for many similar objectsenableBVH on GLTF for faster raycasting on complex meshessetFPSLimit() to cap frame rate when full 60fps isn't neededrenderOnlyWhenThisIsTriggered on <scene> for on-demand rendering (e.g., configurators)meshBasicMaterial for unlit objects — cheaper than meshStandardMaterialObject3DComponentEngineModule is a convenience NgModule that re-exports all engine componentsEngineService is provided per-component (NOT singleton) — each <scene> gets its own engine instancePhysicsService.update() is called each frameEngineService.switchCamera() or the isActive / switchCameraTrigger inputs on camera/orbit components