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 MIN_SPEED_LEVEL = 1
|
||||||
const MAX_SPEED_LEVEL = 10
|
const MAX_SPEED_LEVEL = 10
|
||||||
const DEFAULT_SPEED_LEVEL = 5
|
const DEFAULT_SPEED_LEVEL = 5
|
||||||
|
const MAX_SPEED_FRAME_BUDGET_MS = 12
|
||||||
|
const MAX_SPEED_STEPS_PER_FRAME = 64
|
||||||
|
|
||||||
interface RootConfig {
|
interface RootConfig {
|
||||||
rootName: string
|
rootName: string
|
||||||
@@ -736,16 +738,55 @@ function WindowControls({
|
|||||||
|
|
||||||
function playbackIntervalMs(speedLevel: number) {
|
function playbackIntervalMs(speedLevel: number) {
|
||||||
const clamped = Math.max(MIN_SPEED_LEVEL, Math.min(MAX_SPEED_LEVEL, speedLevel))
|
const clamped = Math.max(MIN_SPEED_LEVEL, Math.min(MAX_SPEED_LEVEL, speedLevel))
|
||||||
|
if (clamped >= MAX_SPEED_LEVEL) return 0
|
||||||
const slowMs = 720
|
const slowMs = 720
|
||||||
const fastMs = 60
|
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)
|
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
|
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({
|
function StudioSpeedKnob({
|
||||||
className = '',
|
className = '',
|
||||||
speedLevel,
|
speedLevel,
|
||||||
@@ -758,13 +799,13 @@ function StudioSpeedKnob({
|
|||||||
const clamped = Math.max(MIN_SPEED_LEVEL, Math.min(MAX_SPEED_LEVEL, speedLevel))
|
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 turn = (clamped - MIN_SPEED_LEVEL) / (MAX_SPEED_LEVEL - MIN_SPEED_LEVEL)
|
||||||
const angle = -132 + turn * 264
|
const angle = -132 + turn * 264
|
||||||
const stepsPerSecond = speedStepsPerSecond(clamped)
|
const readout = speedReadout(clamped)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
className={`studio-speed-knob ${className}`}
|
className={`studio-speed-knob ${className}`}
|
||||||
style={{ '--speed-knob-angle': `${angle}deg` } as React.CSSProperties}
|
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-label">Speed</span>
|
||||||
<span className="speed-knob-face" aria-hidden="true">
|
<span className="speed-knob-face" aria-hidden="true">
|
||||||
@@ -779,7 +820,7 @@ function StudioSpeedKnob({
|
|||||||
value={clamped}
|
value={clamped}
|
||||||
onChange={(event) => onChange(Number(event.target.value))}
|
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>
|
</label>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2294,15 +2335,14 @@ function LibraryEditor({
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!running) return
|
if (!running) return
|
||||||
const timer = window.setInterval(() => {
|
return startPlaybackLoop(speedLevel, () => {
|
||||||
setCells((current) => {
|
setCells((current) => {
|
||||||
const stepped = stepCaSimulation(current, previewSettings)
|
const stepped = stepCaSimulation(current, previewSettings)
|
||||||
setRuntimeSettings(stepped.settings)
|
setRuntimeSettings(stepped.settings)
|
||||||
return stepped.cells
|
return stepped.cells
|
||||||
})
|
})
|
||||||
setGeneration((current) => current + 1)
|
setGeneration((current) => current + 1)
|
||||||
}, playbackIntervalMs(speedLevel))
|
})
|
||||||
return () => window.clearInterval(timer)
|
|
||||||
}, [previewSettings, running, speedLevel])
|
}, [previewSettings, running, speedLevel])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -3518,8 +3558,7 @@ function DeckViewer({ deckId }: { deckId: string }) {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!running) return
|
if (!running) return
|
||||||
const timer = window.setInterval(stepOnce, playbackIntervalMs(speedLevel))
|
return startPlaybackLoop(speedLevel, stepOnce)
|
||||||
return () => window.clearInterval(timer)
|
|
||||||
}, [running, speedLevel, stepOnce])
|
}, [running, speedLevel, stepOnce])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -3726,15 +3765,14 @@ function DeckEditor({ deckId, initialSceneId }: { deckId: string; initialSceneId
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!running) return
|
if (!running) return
|
||||||
const timer = window.setInterval(() => {
|
return startPlaybackLoop(speedLevel, () => {
|
||||||
setCells((current) => {
|
setCells((current) => {
|
||||||
const stepped = stepCaSimulation(current, previewSettings)
|
const stepped = stepCaSimulation(current, previewSettings)
|
||||||
setRuntimeSettings(stepped.settings)
|
setRuntimeSettings(stepped.settings)
|
||||||
return stepped.cells
|
return stepped.cells
|
||||||
})
|
})
|
||||||
setGeneration((current) => current + 1)
|
setGeneration((current) => current + 1)
|
||||||
}, playbackIntervalMs(speedLevel))
|
})
|
||||||
return () => window.clearInterval(timer)
|
|
||||||
}, [previewSettings, running, speedLevel])
|
}, [previewSettings, running, speedLevel])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface SceneBundle {
|
|||||||
bounds: THREE.LineSegments
|
bounds: THREE.LineSegments
|
||||||
camera: THREE.PerspectiveCamera
|
camera: THREE.PerspectiveCamera
|
||||||
controls: OrbitControls
|
controls: OrbitControls
|
||||||
|
glowMesh: THREE.InstancedMesh | null
|
||||||
grid: THREE.GridHelper
|
grid: THREE.GridHelper
|
||||||
mesh: THREE.InstancedMesh | null
|
mesh: THREE.InstancedMesh | null
|
||||||
mountNode: HTMLDivElement
|
mountNode: HTMLDivElement
|
||||||
@@ -61,10 +62,20 @@ function disposeMesh(scene: THREE.Scene, mesh: THREE.InstancedMesh | null) {
|
|||||||
function createVoxelMaterial(primaryColor: THREE.Color) {
|
function createVoxelMaterial(primaryColor: THREE.Color) {
|
||||||
return new THREE.MeshStandardMaterial({
|
return new THREE.MeshStandardMaterial({
|
||||||
color: primaryColor,
|
color: primaryColor,
|
||||||
emissive: primaryColor.clone().multiplyScalar(0.36),
|
emissive: primaryColor.clone().multiplyScalar(0.58),
|
||||||
emissiveIntensity: 0.26,
|
emissiveIntensity: 0.48,
|
||||||
metalness: 0.04,
|
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
|
vertexColors: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -112,6 +123,18 @@ function createVoxelMesh(size: number, options: VoxelRendererOptions) {
|
|||||||
return mesh
|
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 = [
|
const CATEGORICAL_STATE_COLORS = [
|
||||||
'#70f45f',
|
'#70f45f',
|
||||||
'#f0c94a',
|
'#f0c94a',
|
||||||
@@ -195,12 +218,17 @@ function colorForCell(
|
|||||||
|
|
||||||
function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number, ruleId: string, options: VoxelRendererOptions) {
|
function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number, ruleId: string, options: VoxelRendererOptions) {
|
||||||
disposeMesh(bundle.scene, bundle.mesh)
|
disposeMesh(bundle.scene, bundle.mesh)
|
||||||
|
disposeMesh(bundle.scene, bundle.glowMesh)
|
||||||
bundle.mesh = createVoxelMesh(size, options)
|
bundle.mesh = createVoxelMesh(size, options)
|
||||||
|
bundle.glowMesh = createVoxelGlowMesh(size, options)
|
||||||
|
bundle.scene.add(bundle.glowMesh)
|
||||||
bundle.scene.add(bundle.mesh)
|
bundle.scene.add(bundle.mesh)
|
||||||
|
|
||||||
const mesh = bundle.mesh
|
const mesh = bundle.mesh
|
||||||
|
const glowMesh = bundle.glowMesh
|
||||||
const dummy = new THREE.Object3D()
|
const dummy = new THREE.Object3D()
|
||||||
const color = new THREE.Color()
|
const color = new THREE.Color()
|
||||||
|
const glowColor = new THREE.Color()
|
||||||
const spacing = WORLD_SIZE / size
|
const spacing = WORLD_SIZE / size
|
||||||
const origin = -WORLD_SIZE / 2 + spacing / 2
|
const origin = -WORLD_SIZE / 2 + spacing / 2
|
||||||
const statePalette = createStatePalette(cells, options)
|
const statePalette = createStatePalette(cells, options)
|
||||||
@@ -216,14 +244,22 @@ function updateVoxelMesh(bundle: SceneBundle, cells: VoxelCell[], size: number,
|
|||||||
colorForCell(bundle, cell, size, ruleId, statePalette, color)
|
colorForCell(bundle, cell, size, ruleId, statePalette, color)
|
||||||
mesh.setMatrixAt(instanceIndex, dummy.matrix)
|
mesh.setMatrixAt(instanceIndex, dummy.matrix)
|
||||||
mesh.setColorAt(instanceIndex, color)
|
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
|
instanceIndex += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
mesh.count = instanceIndex
|
mesh.count = instanceIndex
|
||||||
|
glowMesh.count = instanceIndex
|
||||||
mesh.instanceMatrix.needsUpdate = true
|
mesh.instanceMatrix.needsUpdate = true
|
||||||
|
glowMesh.instanceMatrix.needsUpdate = true
|
||||||
if (mesh.instanceColor) {
|
if (mesh.instanceColor) {
|
||||||
mesh.instanceColor.needsUpdate = true
|
mesh.instanceColor.needsUpdate = true
|
||||||
}
|
}
|
||||||
|
if (glowMesh.instanceColor) {
|
||||||
|
glowMesh.instanceColor.needsUpdate = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createVoxelRendererCore(targetNode: HTMLDivElement, options: VoxelRendererOptions): VoxelRendererCore {
|
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.shadowMap.type = THREE.PCFSoftShadowMap
|
||||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||||
renderer.toneMappingExposure = 1.12
|
renderer.toneMappingExposure = 1.32
|
||||||
targetNode.appendChild(renderer.domElement)
|
targetNode.appendChild(renderer.domElement)
|
||||||
|
|
||||||
const controls = new OrbitControls(camera, renderer.domElement)
|
const controls = new OrbitControls(camera, renderer.domElement)
|
||||||
@@ -249,16 +285,16 @@ export function createVoxelRendererCore(targetNode: HTMLDivElement, options: Vox
|
|||||||
controls.maxDistance = 60
|
controls.maxDistance = 60
|
||||||
controls.target.set(0, 0, 0)
|
controls.target.set(0, 0, 0)
|
||||||
|
|
||||||
scene.add(new THREE.AmbientLight('#f5efb7', 0.42))
|
scene.add(new THREE.AmbientLight('#f5efb7', 0.72))
|
||||||
scene.add(new THREE.HemisphereLight('#f5efb7', '#061006', 0.96))
|
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.position.set(18, 28, 14)
|
||||||
keyLight.target.position.set(0, 0, 0)
|
keyLight.target.position.set(0, 0, 0)
|
||||||
scene.add(keyLight)
|
scene.add(keyLight)
|
||||||
scene.add(keyLight.target)
|
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)
|
fillLight.position.set(-24, 18, -12)
|
||||||
scene.add(fillLight)
|
scene.add(fillLight)
|
||||||
|
|
||||||
@@ -273,6 +309,7 @@ export function createVoxelRendererCore(targetNode: HTMLDivElement, options: Vox
|
|||||||
bounds,
|
bounds,
|
||||||
camera,
|
camera,
|
||||||
controls,
|
controls,
|
||||||
|
glowMesh: null,
|
||||||
grid,
|
grid,
|
||||||
mesh: null,
|
mesh: null,
|
||||||
mountNode: targetNode,
|
mountNode: targetNode,
|
||||||
@@ -310,6 +347,7 @@ export function createVoxelRendererCore(targetNode: HTMLDivElement, options: Vox
|
|||||||
window.cancelAnimationFrame(bundle.animationFrame)
|
window.cancelAnimationFrame(bundle.animationFrame)
|
||||||
bundle.resizeObserver.disconnect()
|
bundle.resizeObserver.disconnect()
|
||||||
disposeMesh(bundle.scene, bundle.mesh)
|
disposeMesh(bundle.scene, bundle.mesh)
|
||||||
|
disposeMesh(bundle.scene, bundle.glowMesh)
|
||||||
bundle.bounds.geometry.dispose()
|
bundle.bounds.geometry.dispose()
|
||||||
disposeMaterial(bundle.bounds.material)
|
disposeMaterial(bundle.bounds.material)
|
||||||
bundle.grid.geometry.dispose()
|
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 charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>CA Studio Admin</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-D7EhCe8j.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user