Adding a working good enough milestone, revert to this if things break

This commit is contained in:
2026-07-06 14:19:20 +02:00
parent 41e9c76028
commit 09c1fba8e6
4 changed files with 276 additions and 200 deletions
+51 -13
View File
@@ -53,6 +53,8 @@ const GALLERY_TABS: GalleryTab[] = ['decks', 'cas', 'engines', 'icgs']
const MIN_SPEED_LEVEL = 1
const MAX_SPEED_LEVEL = 10
const DEFAULT_SPEED_LEVEL = 5
const MAX_SPEED_FRAME_BUDGET_MS = 12
const MAX_SPEED_STEPS_PER_FRAME = 64
interface RootConfig {
rootName: string
@@ -736,16 +738,55 @@ function WindowControls({
function playbackIntervalMs(speedLevel: number) {
const clamped = Math.max(MIN_SPEED_LEVEL, Math.min(MAX_SPEED_LEVEL, speedLevel))
if (clamped >= MAX_SPEED_LEVEL) return 0
const slowMs = 720
const fastMs = 60
const t = (clamped - MIN_SPEED_LEVEL) / (MAX_SPEED_LEVEL - MIN_SPEED_LEVEL)
const t = (clamped - MIN_SPEED_LEVEL) / (MAX_SPEED_LEVEL - MIN_SPEED_LEVEL - 1)
return Math.round(slowMs - (slowMs - fastMs) * t)
}
function speedStepsPerSecond(speedLevel: number) {
function isMaxPlaybackSpeed(speedLevel: number) {
return speedLevel >= MAX_SPEED_LEVEL
}
function speedReadout(speedLevel: number) {
if (isMaxPlaybackSpeed(speedLevel)) return 'MAX'
return Math.round((1000 / playbackIntervalMs(speedLevel)) * 10) / 10
}
function startPlaybackLoop(speedLevel: number, step: () => void) {
if (!isMaxPlaybackSpeed(speedLevel)) {
const timer = window.setInterval(step, playbackIntervalMs(speedLevel))
return () => window.clearInterval(timer)
}
let cancelled = false
let frame = 0
function tick() {
const startedAt = performance.now()
let steps = 0
while (
!cancelled &&
steps < MAX_SPEED_STEPS_PER_FRAME &&
performance.now() - startedAt < MAX_SPEED_FRAME_BUDGET_MS
) {
step()
steps += 1
}
if (!cancelled) frame = window.requestAnimationFrame(tick)
}
frame = window.requestAnimationFrame(tick)
return () => {
cancelled = true
window.cancelAnimationFrame(frame)
}
}
function StudioSpeedKnob({
className = '',
speedLevel,
@@ -758,13 +799,13 @@ function StudioSpeedKnob({
const clamped = Math.max(MIN_SPEED_LEVEL, Math.min(MAX_SPEED_LEVEL, speedLevel))
const turn = (clamped - MIN_SPEED_LEVEL) / (MAX_SPEED_LEVEL - MIN_SPEED_LEVEL)
const angle = -132 + turn * 264
const stepsPerSecond = speedStepsPerSecond(clamped)
const readout = speedReadout(clamped)
return (
<label
className={`studio-speed-knob ${className}`}
style={{ '--speed-knob-angle': `${angle}deg` } as React.CSSProperties}
title={`Simulation speed: ${stepsPerSecond} steps/s`}
title={readout === 'MAX' ? 'Simulation speed: maximum' : `Simulation speed: ${readout} steps/s`}
>
<span className="speed-knob-label">Speed</span>
<span className="speed-knob-face" aria-hidden="true">
@@ -779,7 +820,7 @@ function StudioSpeedKnob({
value={clamped}
onChange={(event) => onChange(Number(event.target.value))}
/>
<span className="speed-knob-readout">{stepsPerSecond}x</span>
<span className="speed-knob-readout">{readout === 'MAX' ? readout : `${readout}x`}</span>
</label>
)
}
@@ -2294,15 +2335,14 @@ function LibraryEditor({
React.useEffect(() => {
if (!running) return
const timer = window.setInterval(() => {
return startPlaybackLoop(speedLevel, () => {
setCells((current) => {
const stepped = stepCaSimulation(current, previewSettings)
setRuntimeSettings(stepped.settings)
return stepped.cells
})
setGeneration((current) => current + 1)
}, playbackIntervalMs(speedLevel))
return () => window.clearInterval(timer)
})
}, [previewSettings, running, speedLevel])
React.useEffect(() => {
@@ -3518,8 +3558,7 @@ function DeckViewer({ deckId }: { deckId: string }) {
React.useEffect(() => {
if (!running) return
const timer = window.setInterval(stepOnce, playbackIntervalMs(speedLevel))
return () => window.clearInterval(timer)
return startPlaybackLoop(speedLevel, stepOnce)
}, [running, speedLevel, stepOnce])
React.useEffect(() => {
@@ -3726,15 +3765,14 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
React.useEffect(() => {
if (!running) return
const timer = window.setInterval(() => {
return startPlaybackLoop(speedLevel, () => {
setCells((current) => {
const stepped = stepCaSimulation(current, previewSettings)
setRuntimeSettings(stepped.settings)
return stepped.cells
})
setGeneration((current) => current + 1)
}, playbackIntervalMs(speedLevel))
return () => window.clearInterval(timer)
})
}, [previewSettings, running, speedLevel])
React.useEffect(() => {
+46 -8
View File
@@ -30,6 +30,7 @@ interface SceneBundle {
bounds: THREE.LineSegments
camera: THREE.PerspectiveCamera
controls: OrbitControls
glowMesh: THREE.InstancedMesh | null
grid: THREE.GridHelper
mesh: THREE.InstancedMesh | null
mountNode: HTMLDivElement
@@ -61,10 +62,20 @@ function disposeMesh(scene: THREE.Scene, mesh: THREE.InstancedMesh | null) {
function createVoxelMaterial(primaryColor: THREE.Color) {
return new THREE.MeshStandardMaterial({
color: primaryColor,
emissive: primaryColor.clone().multiplyScalar(0.36),
emissiveIntensity: 0.26,
emissive: primaryColor.clone().multiplyScalar(0.58),
emissiveIntensity: 0.48,
metalness: 0.04,
roughness: 0.28,
roughness: 0.68,
vertexColors: true
})
}
function createVoxelGlowMaterial() {
return new THREE.MeshBasicMaterial({
blending: THREE.AdditiveBlending,
depthWrite: false,
opacity: 0.26,
transparent: true,
vertexColors: true
})
}
@@ -112,6 +123,18 @@ function createVoxelMesh(size: number, options: VoxelRendererOptions) {
return mesh
}
function createVoxelGlowMesh(size: number, options: VoxelRendererOptions) {
const spacing = WORLD_SIZE / size
const edge = spacing * (1 - clamp(options.cellGap, 0.02, 0.45)) * 1.18
const geometry = new THREE.BoxGeometry(edge, edge, edge)
const material = createVoxelGlowMaterial()
const mesh = new THREE.InstancedMesh(geometry, material, Math.max(1, size ** 3))
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage)
mesh.castShadow = false
mesh.receiveShadow = false
return mesh
}
const CATEGORICAL_STATE_COLORS = [
'#70f45f',
'#f0c94a',
@@ -195,12 +218,17 @@ function colorForCell(
function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number, ruleId: string, options: VoxelRendererOptions) {
disposeMesh(bundle.scene, bundle.mesh)
disposeMesh(bundle.scene, bundle.glowMesh)
bundle.mesh = createVoxelMesh(size, options)
bundle.glowMesh = createVoxelGlowMesh(size, options)
bundle.scene.add(bundle.glowMesh)
bundle.scene.add(bundle.mesh)
const mesh = bundle.mesh
const glowMesh = bundle.glowMesh
const dummy = new THREE.Object3D()
const color = new THREE.Color()
const glowColor = new THREE.Color()
const spacing = WORLD_SIZE / size
const origin = -WORLD_SIZE / 2 + spacing / 2
const statePalette = createStatePalette(cells, options)
@@ -216,14 +244,22 @@ function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number,
colorForCell(bundle, cell, size, ruleId, statePalette, color)
mesh.setMatrixAt(instanceIndex, dummy.matrix)
mesh.setColorAt(instanceIndex, color)
glowColor.copy(color).lerp(new THREE.Color('#ffffff'), 0.16).multiplyScalar(1.18)
glowMesh.setMatrixAt(instanceIndex, dummy.matrix)
glowMesh.setColorAt(instanceIndex, glowColor)
instanceIndex += 1
}
mesh.count = instanceIndex
glowMesh.count = instanceIndex
mesh.instanceMatrix.needsUpdate = true
glowMesh.instanceMatrix.needsUpdate = true
if (mesh.instanceColor) {
mesh.instanceColor.needsUpdate = true
}
if (glowMesh.instanceColor) {
glowMesh.instanceColor.needsUpdate = true
}
}
export function createVoxelRendererCore(targetNode: HTMLDivElement, options: VoxelRendererOptions): VoxelRendererCore {
@@ -239,7 +275,7 @@ export function createVoxelRendererCore(targetNode: HTMLDivElement, options: Vox
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.outputColorSpace = THREE.SRGBColorSpace
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.12
renderer.toneMappingExposure = 1.32
targetNode.appendChild(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
@@ -249,16 +285,16 @@ export function createVoxelRendererCore(targetNode: HTMLDivElement, options: Vox
controls.maxDistance = 60
controls.target.set(0, 0, 0)
scene.add(new THREE.AmbientLight('#f5efb7', 0.42))
scene.add(new THREE.HemisphereLight('#f5efb7', '#061006', 0.96))
scene.add(new THREE.AmbientLight('#f5efb7', 0.72))
scene.add(new THREE.HemisphereLight('#f5efb7', '#10360d', 1.18))
const keyLight = new THREE.DirectionalLight('#fff3a8', 1.25)
const keyLight = new THREE.DirectionalLight('#fff3a8', 0.82)
keyLight.position.set(18, 28, 14)
keyLight.target.position.set(0, 0, 0)
scene.add(keyLight)
scene.add(keyLight.target)
const fillLight = new THREE.DirectionalLight(options.accentColor, 0.46)
const fillLight = new THREE.DirectionalLight(options.accentColor, 0.72)
fillLight.position.set(-24, 18, -12)
scene.add(fillLight)
@@ -273,6 +309,7 @@ export function createVoxelRendererCore(targetNode: HTMLDivElement, options: Vox
bounds,
camera,
controls,
glowMesh: null,
grid,
mesh: null,
mountNode: targetNode,
@@ -310,6 +347,7 @@ export function createVoxelRendererCore(targetNode: HTMLDivElement, options: Vox
window.cancelAnimationFrame(bundle.animationFrame)
bundle.resizeObserver.disconnect()
disposeMesh(bundle.scene, bundle.mesh)
disposeMesh(bundle.scene, bundle.glowMesh)
bundle.bounds.geometry.dispose()
disposeMaterial(bundle.bounds.material)
bundle.grid.geometry.dispose()