Adding a working good enough milestone, revert to this if things break
This commit is contained in:
+51
-13
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
+178
-178
File diff suppressed because one or more lines are too long
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CA Studio Admin</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-nVGDjB10.js"></script>
|
||||
<script type="module" crossorigin src="/admin/assets/index-COp4VUl7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-D7EhCe8j.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user