This is a programmatic animation library, similar to 3Blue1Brown's Manim or Motion Canvas. It focuses on giving a tight feedback loop for development by seeing changes on save (hot reload) and providing the best capabilities for technical animations, both in 2D and 3D.
-
Hot-reload on save
Tweak code, hit save, and see the change instantly in the viewer instead of re-rendering videos between iterations. -
Built on Three.js
Your scene is a Three.js scene. This allows you to use any Three.js feature and its entire ecosystem. Instead of reinveting the wheel, the users have access to 15 years of collaborative graphics work. -
Quick project setup
Create a ready-to-run project withnpx create-definedmotion my-project. -
Timeline you can reason about
The animation scheduler simply walks a linear timeline. The exposed animation primitives just places functions that are called at each tick/frame. -
Interactive viewer and rendering
Navigate the scene. Position the camera and copy its position to avoid guessing values. When you are happy with your animation, click render to get a video file.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
Manim (community) has great LaTeX capabilities, ease of use, and community, but a relatively weak 3d engine, slow performance and slow iterations.
Motion Canvas is great but is mostly 2D and its heavy use of generator functions can be a bit unconventional.
Remotion fits the niche of making scenes with React, but is more for motion graphics than general technical animations, it is also based on a custom free/commercial license depending on use case.
| Feature | DefinedMotion | Manim (Community) | Motion_Canvas |
|---|---|---|---|
| Performance | Realtime playback in the viewer, even for heavy 3D scenes – no video render needed while iterating. | Built for offline renders; seeing scenes often means waiting for a render | Realtime 2D playback in the viewer. No video render needed while iterating. However, no built-in 3D engine. |
| Hot reload | Hot reload on save as a core workflow with timeline scrubbing. | File-watch/CLI loops exist, but you still wait for each render (no true live hot reload) | Hot reload on save as a core workflow with timeline scrubbing. |
| 3D & rendering engine | Full Three.js ecosystem: PBR materials, lights, HDRI, helpers, post-processing, addons etc | Custom engine, 2D-first; 3D is possible but with less engine/ecosystem depth than Three.js | Designed for 2D Canvas. Great toolkit for this. |
| 3D model import | Use any Three.js loader (GLTF/GLB, OBJ, FBX, STL, etc.) – imported models become first-class scene objects | 3D object support is more limited; importing arbitrary 3D formats is possible but not a core focus | No native 3D mesh import; typically you work with shapes, images, and SVG in 2D |
| Viewer & interaction | Interactive 3D and 2D viewer with timeline and helpers for camera handling. | A preview window. Often rendering to video and watching that. | Great UI. Interactive 2D viewer with timeline and many helpers |
| Low-level control | Low-level access: you work directly with Three.js objects and it's easy to build your own animation primitives | Object model is extensible but more opinionated, deeper engine changes take more work. | Somewhat modular. Possibly awkward due to heavy use of generator functions and custom made engine. |
| LaTeX & math text | LaTeX → SVG → 3D, plus APIs to query positions of substrings (for precise highlights, braces, arrows, etc.). LaTeX becomes true 3D. | Excellent LaTeX support out of the box, huge example base; finer spatial control is more manual | Great 2D LaTeX support with transitions. |
| Install & first run | npx create-definedmotion my-project – one line and you have it set up |
Python env + Manim install, well-documented, lots of community resources. Very heavy LaTeX dependecy (~3–5GB). | Nice easy setup. Uses npm like DM |
| Rendering to video | One-click render in the viewer, you only need ffmpeg when you’re ready for the final video |
Mature CLI rendering. Requires ffmpeg. | Easily rendering from the viewer. Requires ffmpeg. |
| Chatbot / AI support | Very good: all major chatbots understand TypeScript + Three.js, so they can help with almost everything even though DefinedMotion is new | Very good: Manim has a huge footprint; plus Python is well supported by chatbots | Good; smaller ecosystem than Three.js means fewer pre-existing examples for assistants to draw from. |
| Best fit for… | Technical animations in general, complex heavy scenes, math/CS/physics visuals, and Three.js-native workflows with fast iteration & hot reload | Math lectures, proofs, blackboard-style animations, especially in Python-centric stacks | 2D explainers with a visual timeline and audio sync. Nice primitives for building flexbox-like layouts and showing code |
DefinedMotion includes 12 example scenes and 34 tests to help you learn and verify functionality. Browse /src/example_scenes to see complete, working animations you can run immediately.
export const yourSceneName = (): AnimatedScene => {
return new AnimatedScene(1920, 1080, SpaceSetting.ThreeDim,
HotReloadSetting.TraceFromStart, async (dm) => {
})
} return new AnimatedScene(1920, 1080, SpaceSetting.ThreeDim,
HotReloadSetting.TraceFromStart, async (dm) => {
...
dm.addAnims(/* add animations, these will run in parallel*/)
dm.addDeferredAnims(/* add animations functions, these will run in parallel.
The animations will be evaluated later to use the values that will be when the animation starts.*/)
dm.onEachTick((frame, time) => {
/* Run this function for every tick/frame */
/* This is often used to set up dependencies or calculated movements */
/* Conceptually it can be "On each tick, set the line endings at the position of sphere A and sphere B", this will make the line updated regardless of what happens to sphere A and B */
})
dm.do(() => {
/* Add instruction at current tick/frame.
This can be any function, it will be called at the tick.
Often used to for example add elements to the scene
*/
})
dm.doAt(frame, () => {
/* Add instruction at a certain tick/frame.
This can be any function, it will be called at the tick.
Often used to for example add elements to the scene
*/
})
dm.addWait(1000) //Will add an animation that does nothing (waits) for the duration
dm.insertAnimsAt(frame, /* animations */ ) // Works like addAnims(...) but you can just insert an animation anywhere anytime. You can insert animations in the future or present during onEachTick. This is very powerful for complex animations.
dm.addSequentialBackgroundAnims(/* Animations, these will run in sequence */) // This function allows you to add animations that will not push the timeline pointer, if you are at frame X and add an animation that is 300 frames long, this will not make the next added thing to be at X+300, but instead just X (because this adds it in the "background").
// Register an audio before use, this function is often used in the absolute beginning of the scene.
dm.registerAudio(/* audio path */)
// Anywhere in the code (but after registerAudio of the sound), play the sound
dm.playAudio(/* audio path */, volume)
...
})- Run
npx create-definedmotion project_name - Install all dependencies with
npm install - Run the animation viewer with
npm run dev - Add your scene in src/scenes
- Update the src/entry.ts file to use your animation.
- When you want to render your animation, click "Render". You will need to have ffmpeg on your system and available in your system PATH.
The scheduler is the core system that manages when animations run, when instructions execute, and how your scene progresses through time. It's designed to be simple and predictable while giving you complete control over timing.
DefinedMotion uses a two-phase execution model:
-
Planning Phase (Build Time): When your scene function is called, the entire animation is planned upfront. The scheduler records what should happen at each tick—animations, instructions, dependencies—but doesn't execute anything yet. Think of it like writing a script for a play.
-
Runtime Phase: After planning is complete, the scheduler executes the plan tick-by-tick, running animations and updating your scene based on the blueprint created during planning.
This separation is crucial for features like hot reload, timeline scrubbing, and rendering—the scheduler can jump to any frame because it knows the complete plan.
The scheduler operates on a tick-based timeline, where each tick represents one frame of your animation. Think of it like a filmstrip—each tick is one frame, and the scheduler decides what happens at each frame.
Tick: 0 1 2 3 4 5 6 7 8
|----|----|----|----|----|----|----|----|
Anim: [=======animation=======]
⚡ instruction
Deps: ●----●----●----●----●----●----●----●----●
(runs every tick)
What you see:
[====]= Animation spanning multiple ticks⚡= Instruction executing at one tick●----●= Dependencies updating every tick
The scheduler maintains an internal calculation tick that tracks where you are in the scene setup during the planning phase. When you add animations or instructions, they're scheduled relative to this pointer:
// You're at tick 0
scene.addAnims(animation1) // Runs from tick 0–499
// Now you're at tick 500
scene.addAnims(animation2) // Runs from tick 500–999
// Now you're at tick 1000The calculation tick only moves forward when you add animations or a wait (just an animation that does nothing). Instructions (do(), doAt()) and dependencies (onEachTick()) don't move it.
Schedules animations to run in parallel starting at the current tick, then advances the timeline by the longest animation's duration.
// Both fade in together, scene advances by 800 ticks (longest duration)
scene.addAnims(
fadeIn(sphere, 800),
fadeIn(cube, 500)
)Like addAnims(), but animations are created when they actually run (at runtime), not during scene setup (at plan/build time). This lets you use live values instead of values captured at plan time.
// BAD: easeInOutQuad is called during planning phase
// It reads sphere.position.x at plan time (probably 0)
// Creates interpolation from 0 → 10
scene.addAnims(
createAnim(easeInOutQuad(sphere.position.x, 10, 500), (value) => {
sphere.position.x = value
})
)
// GOOD: easeInOutQuad is called at runtime when animation starts
// It reads sphere.position.x at runtime (after previous animations moved it)
// Creates interpolation from current position → 10
scene.addDeferredAnims(
() => createAnim(easeInOutQuad(sphere.position.x, 10, 500), (value) => {
sphere.position.x = value
})
)Important: The scheduler still needs to know duration at planning time, so it calls each factory once during the planning phase just to measure duration, then calls it again at runtime to get the actual animation with current values.
Inserts animations at any specific tick, even from inside onEachTick(). This is powerful for complex conditional logic.
scene.onEachTick((tick) => {
if (collision detected at tick 500) {
// Dynamically insert an explosion animation
scene.insertAnimsAt(500, explosionAnim)
}
})Executes a function at the current tick. Doesn't advance the timeline.
scene.do(() => {
scene.add(newObject)
console.log('Added at tick', scene.getCurrentTimeMs())
})Executes a function at a specific tick (not limited to the schedulers current position).
scene.doAt(1000, () => {
object.material.color.set(0xff0000)
})Registers a function that runs every single tick throughout the entire animation. Great to produce calculated movements or update dependencies like the line ends below.
// Keep line endpoints synced with moving spheres
scene.onEachTick((tick, timeMs) => {
line.updatePositions(sphereA.position, sphereB.position)
})Runs animations in sequence but doesn't advance the timeline. Useful for background activity.
// Timeline stays at 0, but background sequence runs for 1500 ticks
scene.addSequentialBackgroundAnims(
animation1, // 0–499
animation2, // 500–999
animation3 // 1000–1499
)
// Next addAnims() still starts at tick 0
scene.addAnims(mainAnimation)Advances the timeline without any visible changes. Useful for pacing.
scene.addAnims(fadeIn(object, 500))
scene.addWait(2000) // Hold for 2 seconds
scene.addAnims(fadeOut(object, 500))When the scheduler processes a single tick at runtime, it executes in this order:
- Instructions (
do(),doAt()) — Scene modifications - Animations (
addAnims(),insertAnimsAt()) — Property updates - Dependencies (
onEachTick()) — Derived updates
This ordering ensures dependencies always see the latest state from animations.
The scheduler supports three hot reload modes:
- TraceFromStart: Replays values updates for all ticks from 0 to current when you save. Accurate but slow for long scenes.
- BeginFromCurrent: Jumps directly to current tick using only instructions before it. Fast but may miss accumulated state (like
angle += 0.01). - BeginFreshOnSave: Restarts from tick 0 when you save.
Example of accumulated state issue:
let angle = 0
scene.onEachTick(() => {
angle += 0.01 // Accumulates over time
sphere.rotation.y = angle
})
// With BeginFromCurrent, angle won't be correct at tick 500
// With TraceFromStart, it will beAudio is scheduled alongside animations:
scene.registerAudio(audioPath) // Load first
scene.do(() => {
scene.playAudio(audioPath, volume) // Plays at current tick
})During rendering, audio events are collected and exported as a soundtrack synced to the video.
The scheduler provides helpers for converting between ticks and time:
const ms = ticksToMillis(ticks) // Ticks → milliseconds
const t = millisToTicks(ms) // Milliseconds → ticks
const fps = renderOutputFps() // Final render frame rate- The entire animation is planned upfront during the planning phase, then executed tick-by-tick at runtime
- The scheduler is deterministic: same code always produces same animation
- Use
addDeferredAnims()when you need values at runtime, not values at plan/build time onEachTick()runs every frame and sees all prior updatesinsertAnimsAt()enables dynamic, conditional animations- Background sequences let you layer independent timelines
- Hot reload modes trade accuracy for speed during development
DefinedMotion's animation system is built on a simple but powerful concept: interpolations are just arrays of numbers, and animations pair those arrays with updater functions.
An interpolation is simply a number[] — an array of pre-calculated values, one per frame. That's it. During the planning phase, these arrays are generated once and stored. At runtime, the scheduler walks through them tick-by-tick.
// This creates an array like [0, 0.02, 0.08, 0.18, ..., 9.82, 9.92, 9.98, 10]
const interpolation = easeInOutQuad(0, 10, 500) // 500ms from 0 to 10
console.log(interpolation.length) // Number of frames (ticks)
console.log(interpolation[0]) // 0
console.log(interpolation[100]) // Some intermediate valueThis approach has major advantages:
- Predictable: Same input always produces same output
- Fast: No expensive easing calculations at runtime
- Flexible: Easy to combine, reverse, or modify
- Debuggable: You can inspect the exact values
DefinedMotion provides several interpolation generators:
Linear interpolation from start to end.
const linear = easeLinear(0, 100, 1000) // [0, 1, 2, 3, ..., 98, 99, 100]Smooth quadratic easing (slow start, fast middle, slow end).
const smooth = easeInOutQuad(0, 10, 800) // Smooth acceleration and decelerationHolds a constant value for a duration (useful for waits or holds).
const hold = easeConstant(5, 2000) // Stays at 5 for 2 secondsOvershoots the target then settles (elastic effect).
const bounce = rubberband(0, 100, 1000) // Goes past 100, then settles backSince interpolations are just arrays, you can manipulate them:
// Concatenate: move, hold, return
const sequence = concatInterpols(
easeInOutQuad(0, 10, 500), // Move right
easeConstant(10, 1000), // Hold
easeInOutQuad(10, 0, 500) // Move back
)
// Or use array methods
const reversed = easeLinear(0, 10, 500).reverse()
const doubled = [...interpolation, ...interpolation] // Play twiceAn animation pairs an interpolation with an updater function that applies each value:
// Basic pattern: createAnim(interpolation, updater)
const animation = createAnim(
easeInOutQuad(0, 10, 1000), // The interpolation (what values)
(value) => { // The updater (what to do with them)
sphere.position.x = value
}
)The updater function receives three parameters:
value: Current interpolation value for this ticktick: Current scene tick (frame number)isLast: Boolean indicating if this is the final frame
const animation = createAnim(interpolation, (value, tick, isLast) => {
sphere.position.x = value
if (tick % 10 === 0) {
console.log('Every 10th frame:', value)
}
if (isLast) {
console.log('Animation finished!')
}
})createAnim() returns a UserAnimation object with helpful methods:
const anim = createAnim(easeInOutQuad(0, 10, 1000), (v) => sphere.position.x = v)
// Reverse the animation
const backward = anim.copy().reverse()
anim.scaleLength(2.0) // Now takes 2 seconds instead of 1
// Add noise/jitter to the values
anim.addNoise(0.5) // Adds random variation up to ±0.5
// Chain modifications
const modified = anim.copy()
.scaleLength(1.5)
.addNoise(0.2)
.reverse()
// Always copy before modifying to preserve the original
scene.addAnims(anim.copy().reverse()) // Don't affect original animYou can create your own interpolation functions:
// Custom exponential easing
function easeExponential(start: number, end: number, durationMs: number): number[] {
const n = millisToTicks(durationMs)
if (n <= 1) return [end]
const k = 5
const ek = Math.exp(k)
return Array.from({ length: n }, (_, i) => {
const t = i / (n - 1)
const eased = (Math.exp(k * t) - 1) / (ek - 1) // 0..1 → 0..1
return start + (end - start) * eased
})
}
// Use it like any built-in interpolation
const anim = createAnim(
easeExponential(0, 100, 1000),
(value) => object.scale.setScalar(value)
)DefinedMotion includes common animations that use this system:
// These all return UserAnimation objects
fadeIn(sphere, 800) // Opacity 0 → 1
fadeOut(sphere, 800) // Opacity 1 → 0
zoomIn(sphere, 800) // Scale 0 → 1
fade(sphere, 800, 0.5, 1) // Custom opacity range
// Camera animations (return deferred factories)
moveCameraToAnim(camera, { position: new THREE.Vector3(10, 0, 0) }, 1000)
rotateCameraToAnim(camera, { rotation: quaternion }, 1000)
flyCameraToAnim(camera, { position: pos, rotation: quat }, 1500)
// LaTeX animations (return deferred factories)
latexHighlightAnim(latexGroup, 'myClass', { durationMs: 1000 })
latexMarkAnim(latexGroup, ['lhs', 'rhs'], { pulses: 3 })
latexParticleTransitionAnim(fromGroup, toGroup, { particleCount: 3000 })
latexWriteAnim(latexGroup, { direction: 'ltr', penWidth: 0.15 })- Interpolations are just
number[]— simple, predictable, and fast - Animations = interpolation + updater function
- Built during the planning phase, executed at runtime
- Easy to modify: reverse, scale, add noise, concatenate
- Custom interpolations are just functions that return
number[] - The
UserAnimationclass provides helpful methods for manipulation - All high-level animations (fade, zoom, camera moves) are built on this same simple system
DefinedMotion provides a complete toolkit for creating, animating, and transforming LaTeX expressions in 3D space. The system converts LaTeX to SVG to Three.js meshes, with full support for positioning, styling, and animation.
The LaTeX pipeline follows this flow:
- LaTeX string →
latexToSVG()→ SVG markup (via MathJax) - SVG markup →
createSVGShape()→ Three.js Group with geometry - Three.js Group → animate, transform, query, or update in your scene
All LaTeX is rendered as true 3D geometry that inherits Three.js capabilities (materials, lights, shadows, transformations).
Rendering & Creation
latexToSVG(latex: string, { display?: boolean }): string— Convert LaTeX to SVG markup using MathJaxcreateSVGShape(svg: string, targetWidth: number, detail?: number): THREE.Group— Convert SVG to 3D geometry with normalized sizingupdateSVGShape(group: THREE.Group, svg: string, opts?): THREE.Group— Update existing LaTeX group with new content (preserves transforms)
Querying & Positioning
queryLaTeXClass(root: THREE.Object3D, className: string): ClassQueryResult | null— Find meshes by class, returns{ meshes, box, center }for positioning overlays or animations
Animation Helpers (all return deferred animation factories for use with addDeferredAnims)
latexMarkAnim(root, classNames, { durationMs?, color?, padding?, pulses?, scaleAmp? })— Pulsating bracket overlay around specified classeslatexHighlightAnim(root, classNames, { durationMs?, highlightColor?, pulses?, minMix?, maxMix? })— Color pulse animation on specified classeslatexParticleTransitionAnim(fromGroup, toGroup, { durationMs?, particleCount? })— Morphs one formula into another via particle dispersionlatexWriteAnim(targetGroup, { durationMs?, direction?, penWidth? })— Reveals formula with writing effect (left-to-right or right-to-left)
Use the \dmClass{tag}{content} macro to mark parts of your LaTeX for querying or targeted animation:
\dmClass{lhs}{\int_0^\infty e^{-x^2}\,dx} = \dmClass{rhs}{\frac{\sqrt{\pi}}{2}}Then query or animate by class name:
const result = queryLaTeXClass(group, 'lhs')
// result is general spatial information of the left-hand side, tagged with "lhs". Functions like "latexHighlightAnim" are built on top of "queryLaTeXClass"
dm.addDeferredAnims(
latexHighlightAnim(group, 'rhs', { highlightColor: 0xff0000 })
)Quick setup with pre-configured lighting:
import { addSceneLighting } from '$renderer/lib/rendering/lighting3d'
addSceneLighting(scene.scene, {
colorScheme: 'cool', // 'cool' | 'warm' | 'contrast' | 'studio' | 'dramatic'
intensity: 1.0
})Simple gradient backgrounds:
import { addBackgroundGradient } from '$renderer/lib/rendering/lighting3d'
addBackgroundGradient({
scene,
topColor: 0x87ceeb,
bottomColor: 0xffffff,
lightingIntensity: 1.0
})HDRIs provide realistic environment lighting and reflections. Use a two-step approach for performance:
Step 1: Load once at module scope
import { loadHDRIData, addHDRI, HDRIs } from '$renderer/lib/rendering/hdri'
const hdriData = await loadHDRIData(
HDRIs.outdoor1, // photoStudio1/2/3, outdoor1, indoor1, metro1
2 // blur amount (0=sharp, 5+=very blurred)
)Step 2: Apply in your scene
export function myScene(): AnimatedScene {
return new AnimatedScene(1920, 1080, SpaceSetting.ThreeDim,
HotReloadSetting.TraceFromStart, async (scene) => {
await addHDRI(
scene,
hdriData,
1.0, // lighting intensity
1.0 // background opacity (0=invisible, 1=fully visible)
)
})
}Why two steps? Loading HDRIs is expensive. Loading it outside the scene build function, it doesn't have to reload when it doesn't need to which produces a smoother viewer.
Mix techniques:
// HDRI for lighting only (no visible background)
await addHDRI(scene, hdriData, 1.0, 0.0)
// Add custom background
addBackgroundGradient({ scene, topColor: 0x000033, bottomColor: 0x000000 })
// Add accent lights
const light = new THREE.DirectionalLight(0xffffff, 2.0)
light.position.set(5, 5, 5)
scene.add(light)Key points:
- Load HDRIs at module scope, not inside scene functions
- Higher blur = softer background, better performance
- Use
MeshStandardMaterialorMeshPhysicalMaterialfor PBR - Combine HDRIs with gradients and traditional lights as needed
DefinedMotion syncs audio to your timeline automatically. Audio is scheduled at specific ticks and stays perfectly in sync during playback, scrubbing, and rendering.
Step 1: Register audio files (once, early in scene)
import tickSound from '$assets/audio/tick_sound.mp3'
scene.registerAudio(tickSound) // Tell the scene about this audio fileStep 2: Play audio at any point
scene.do(() => {
scene.playAudio(tickSound, 1.0) // Play at current tick, volume 1.0
})
// Or play at a specific tick
scene.doAt(500, () => {
scene.playAudio(tickSound, 0.5) // Play at tick 500, volume 0.5
})Trigger sounds during animations:
scene.registerAudio(tickSound)
let lastIndex = -1
const switchAnimation = createAnim(
easeLinear(0, 1, 3000),
(value) => {
const index = Math.floor(value * 10)
if (index !== lastIndex) {
lastIndex = index
scene.playAudio(tickSound) // Play on each change
}
}
)
scene.addAnims(switchAnimation)During playback:
- Audio plays immediately when its tick is reached
- Pause/resume works seamlessly (audio resumes from correct position)
- Timeline scrubbing automatically seeks audio to the correct time
During rendering:
- Audio events are collected with their frame numbers
- Exported as a synchronized audio track in the final video
- No need to manually sync audio in post-production
During planning:
registerAudio()tells the scene which files to loadplayAudio()schedules the sound at the current tick- Audio loads asynchronously before playback starts
- Always
registerAudio()before usingplayAudio() - Audio is tick-synchronized (stays in sync with animations)
- Volume range: 0.0 (silent) to 1.0 (full volume)
- Audio automatically handles: playback, pause/resume, seeking, and render export
- Multiple sounds can play simultaneously
DefinedMotion provides convenient path aliases to avoid messy relative imports like ../../../../assets/.
$renderer/* - Access any file in src/renderer/src/
// Instead of: import { AnimatedScene } from '../../../../renderer/src/lib/scene/sceneClass'
import { AnimatedScene } from '$renderer/lib/scene/sceneClass'
import { fadeIn } from '$renderer/lib/animation/animations'
import { createCircle } from '$renderer/lib/rendering/objects2d'
import { addHDRI, HDRIs } from '$renderer/lib/rendering/hdri'$assets/* - Access any file in src/assets/
// Instead of: import tickSound from '../../../../assets/audio/tick_sound.mp3'
import tickSound from '$assets/audio/tick_sound.mp3'
import myImage from '$assets/images/photo.jpg'
import customHDRI from '$assets/hdri/custom-environment.hdr'These shortcuts work throughout your project—no configuration needed. They're defined in tsconfig.json and work automatically.
// Goal for this animation:
// Move a circle back and forth and continually change its color
// Step 1: Create function that returns AnimatedScene
export function tutorial_easy1(): AnimatedScene {
// We return an animated scene that has some settings and lastly has a callback function
// The first two parameters are resolution, this will be a vertical clip
// The third argument sets if we want 3D or 2D
// The forth allows us to say how hot reload should be handled,
// With trace from start, at hot reload, the actions of all frames before the current, will be accounted for.
// If you don't have accumulative changes (or if its fine without for debug), then it's much faster to use "HotReloadSetting.BeginFromCurrent" since it will only have to calculate the current frames actions.
return new AnimatedScene(
1080,
1920,
SpaceSetting.TwoDim,
HotReloadSetting.TraceFromStart,
(dm) => {
// Helper function to create a "THREE.CircleGeometry"
// You can just use any Three.js code if you want
const circle = createCircle(5)
// Add our circle to the scene
dm.add(circle)
// Create an animation that makes it move from left to right
// This is very modular and easy to build on
// createAnim takes two argument, an interpolation (just calculated number[]), and a call back function where you can use each value
// So here we are creating the interpolation with easeInOutQuad: number[]
// And give a function that is called for each frame with the current interpolation value
const anim = createAnim(easeInOutQuad(-5, 5, 500), (value) => (circle.position.x = value))
// We use "addAnims" to schedule an animation, it will run from the frame (tick) it was added at
// Since this is our first added animation in this scene, we are currently at tick 0, So it will just add to the start.
// But say that we are in a complex animation and our previous buildings would mean that we are at frame 49878 for example (we wouldn't know this)
// Then it just adds the animation with that offset
dm.addAnims(anim)
// To make the circle also go back, we can reverse the entire animation and add it again
// Notice that we are copying it, this is so that the reverse() doesn't affect the original variable "anim"
dm.addAnims(anim.copy().reverse())
// We now finally add a function that will be called at each frame (tick) in our animation
// This doesn't push the tick forward like the "addAnims" does.
// It just declares a function that should be run at each frame
// For this animation, we want to set a color to the circle at each frame.
dm.onEachTick((tick) => {
circle.material.color = new THREE.Color().setRGB(posXSigmoid(circle.position.x / 4), 1, 1)
})
}
)
}
// Goal for this animation:
// 1) Render a time-varying mathematical surface (z = f(x, y, t))
// 2) Add a glowing orb with a point light
// 3) Animate the camera on a smooth orbit while the surface deforms
// ─────────────────────────────────────────────────────────────────────────────
// Step 0: Materials used by our surface and our glowing sphere
// MeshStandardMaterial gives us physically-based shading that reacts to lights.
// For the sphere, we use a strong emissive color so it "glows" even without light.
// ─────────────────────────────────────────────────────────────────────────────
const surfaceMaterial = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 0.8,
roughness: 0.1,
side: THREE.DoubleSide
}) as any // cast to any to satisfy TS if createFunctionSurface has a stricter type
const sphereMaterial = new THREE.MeshStandardMaterial({
color: 0x000000, // base color (almost irrelevant since emissive dominates)
emissive: 0xffffff, // self-illumination color
emissiveIntensity: 200 // strong glow
})
// ─────────────────────────────────────────────────────────────────────────────
// Step 1: A time-dependent function that returns a surface height function.
// We return a function (x, y) => z that changes smoothly over time.
// Try tweaking constants to see different wave behaviors.
// ─────────────────────────────────────────────────────────────────────────────
const sineTimeFunction = (time: number): ((x: number, y: number) => number) => {
return (x: number, y: number) =>
(5 * (Math.sin(x * 2 + time) * Math.cos(y * 2 + time))) /
(Math.pow(Math.abs(x) + Math.abs(y), 2) + 5) +
3
}
// ─────────────────────────────────────────────────────────────────────────────
// Step 2: Export an AnimatedScene just like in tutorial 1, but in 3D.
// We use HotReloadSetting.BeginFromCurrent since it will be much faster during debug.
// Since this has accumulative effects (angle += 0.005) notice that the angle is not correct at hot reload.
// Regardless of our HotReloadSetting, the renders will always be correct
// ─────────────────────────────────────────────────────────────────────────────
export function tutorial_easy2(): AnimatedScene {
return new AnimatedScene(
1500, // width (square clip)
1500, // height
SpaceSetting.ThreeDim, // 3D scene
HotReloadSetting.BeginFromCurrent,
async (scene) => {
// ───────────────────────────────────────────────────────────────────────
// Step 3: Basic environment (background gradient)
// ───────────────────────────────────────────────────────────────────────
addBackgroundGradient({
scene,
topColor: 0x0c8ccd, // blue-ish
bottomColor: 0x000000, // black
lightingIntensity: 10
})
// We use three.js directly to create a grid and axes
const gridHelper = new THREE.GridHelper(20, 20)
const axesHelper = new THREE.AxesHelper(20)
scene.add(gridHelper, axesHelper)
// ───────────────────────────────────────────────────────────────────────
// Step 4: Create a function surface over a domain.
// We start with t = 0, then update it every frame in onEachTick.
// The returned object is a THREE.Mesh we can style like any other mesh.
// ───────────────────────────────────────────────────────────────────────
const DOMAIN: [number, number, number, number] = [-7, 7, -7, 7] // [xMin, xMax, yMin, yMax]
const surface = createFunctionSurface(sineTimeFunction(0), ...DOMAIN)
surface.material = surfaceMaterial
scene.add(surface)
// ───────────────────────────────────────────────────────────────────────
// Step 5: Add a glowing sphere with a point light.
// We put them in a Group so they move together if we want.
// The light’s position is kept in sync with the sphere.
// ───────────────────────────────────────────────────────────────────────
const sphere = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 32), sphereMaterial)
const pointLight = new THREE.PointLight(0xffffff, 50) // (color, intensity)
pointLight.position.copy(sphere.position)
const orbGroup = new THREE.Group()
orbGroup.add(sphere, pointLight)
orbGroup.position.y = 6 // float above the surface a bit
scene.add(orbGroup)
// ───────────────────────────────────────────────────────────────────────
// Step 6: Camera setup + gentle orbit animation.
// We place the camera, measure its distance to the origin, and then
// slowly orbit around (0,0,0). The wobble changes radius over time.
// Thees numbers for the position is difficult to guess,
// the workflow is therefore to navigate the scene to where you want it and then copy the positions it prints.
// This avoids awkward value guessing for position and rotation
// ───────────────────────────────────────────────────────────────────────
scene.camera.position.set(3.889329, 7.895859, 10.51772)
scene.camera.rotation.set(-0.6027059, 0.3079325, 0.2056132)
const LOOK_AT = new THREE.Vector3(0, 0, 0)
const baseRadius = scene.camera.position.distanceTo(LOOK_AT)
let angle = 0
// ───────────────────────────────────────────────────────────────────────
// Step 7: Per-frame updates.
// - Update the surface with the current time (tick)
// - Keep the orb group floating
// - Orbit the camera and keep it looking at the center
// ───────────────────────────────────────────────────────────────────────
scene.onEachTick((tick) => {
const t = tick / 20
const f = sineTimeFunction(t)
updateFunctionSurface(surface, f, ...DOMAIN)
// Keep the orb hovering above the surface
orbGroup.position.y = 6
// Camera orbit with a subtle radius wobble
angle += 0.005
const wobble = (Math.sin(tick / 50) + 2) / 2 // ranges roughly [0.5, 1.5]
scene.camera.position.x = Math.sin(angle) * baseRadius * wobble
scene.camera.position.z = Math.cos(angle) * baseRadius * wobble
scene.camera.lookAt(LOOK_AT)
})
// ───────────────────────────────────────────────────────────────────────
// Step 8: Let the animation run for a while before finishing.
// This adds 10 seconds of "play time" to the scene’s schedule.
// ───────────────────────────────────────────────────────────────────────
scene.addWait(10_000)
}
)
}If you have any questions, feel free to contact me Hugo Olsson at hugo.contact01@gmail.com






