Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion src/components/editor/scene-config-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@

import { useCallback } from "react"
import { ChannelMixerMatrix } from "@/components/ui/channel-mixer-matrix"
import { ColorPicker } from "@/components/ui/color-picker"
import { ColorCurvesEditor } from "@/components/ui/color-curves"
import { ColorPicker } from "@/components/ui/color-picker"
import { GradientRamp, type GradientStop } from "@/components/ui/gradient-ramp"
import { NumberInput } from "@/components/ui/number-input"
import { Select } from "@/components/ui/select"
import { Slider } from "@/components/ui/slider"
import { Toggle } from "@/components/ui/toggle"
import { Typography } from "@/components/ui/typography"
import {
GRAPHICS_PRESET_LABELS,
type GraphicsPreset,
type GraphicsPresetMode,
} from "@/lib/editor/graphics-preset"
import { playUISound } from "@/lib/audio/shader-lab-sounds"
import { useEditorStore } from "@/store/editor-store"
import { useGraphicsPresetStore } from "@/store/graphics-preset-store"
import type { CompositionAspect, SceneConfig } from "@/types/editor"
import { COMPOSITION_ASPECTS, DEFAULT_SCENE_CONFIG } from "@/types/editor"

Expand All @@ -25,6 +31,19 @@ const aspectOptions = COMPOSITION_ASPECTS.map((aspect) => ({
value: aspect,
}))

function buildGraphicsPresetOptions(detected: GraphicsPreset | null) {
const autoLabel = detected
? `Auto (${GRAPHICS_PRESET_LABELS[detected]})`
: "Auto"

return [
{ label: autoLabel, value: "auto" },
{ label: GRAPHICS_PRESET_LABELS.performance, value: "performance" },
{ label: GRAPHICS_PRESET_LABELS.balanced, value: "balanced" },
{ label: GRAPHICS_PRESET_LABELS.quality, value: "quality" },
]
}

const inputClassName =
"h-7 w-14 rounded-[var(--ds-radius-control)] border border-[var(--ds-border-divider)] bg-[var(--ds-color-surface-control)] px-2 text-center font-[var(--ds-font-mono)] text-[11px] leading-4 text-[var(--ds-color-text-primary)] outline-none focus:border-[var(--ds-border-active)]"

Expand Down Expand Up @@ -68,6 +87,42 @@ function Row({
)
}

function GraphicsSection() {
const mode = useGraphicsPresetStore((state) => state.mode)
const detected = useGraphicsPresetStore((state) => state.detected)
const setMode = useGraphicsPresetStore((state) => state.setMode)
const resetDetection = useGraphicsPresetStore((state) => state.resetDetection)

const options = buildGraphicsPresetOptions(detected)

const handleChange = useCallback(
(value: string | null) => {
if (!value) return
const next = value as GraphicsPresetMode
if (next === "auto") {
resetDetection()
}
setMode(next)
},
[setMode, resetDetection]
)

const detectedLabel = detected ? GRAPHICS_PRESET_LABELS[detected] : null

return (
<Section title="Graphics">
<Row label="Graphics Preset">
<Select onValueChange={handleChange} options={options} value={mode} />
</Row>
<Typography tone="muted" variant="monoXs">
{detectedLabel
? `Recommended for this device: ${detectedLabel}. Auto applies the detected recommendation.`
: "Detecting recommended settings for this device…"}
</Typography>
</Section>
)
}

export function SceneConfigContent() {
const sceneConfig = useEditorStore((state) => state.sceneConfig)
const updateSceneConfig = useEditorStore((state) => state.updateSceneConfig)
Expand Down Expand Up @@ -144,6 +199,8 @@ export function SceneConfigContent() {
</Row>
</Section>

<GraphicsSection />

{/* Color Adjustments */}
<Section
action={
Expand Down
5 changes: 5 additions & 0 deletions src/components/pages/shader-lab-page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
"use client"

import { EditorCanvasViewport } from "@/components/editor/editor-canvas-viewport"
import { MobileEditorDock } from "@/components/editor/mobile-editor-dock"
import { EditorShortcuts } from "@/components/editor/editor-shortcuts"
import { EditorTimelineOverlay } from "@/components/editor/editor-timeline-overlay"
import { EditorTopBar } from "@/components/editor/editor-topbar"
import { LayerSidebar } from "@/components/editor/layer-sidebar"
import { PropertiesSidebar } from "@/components/editor/properties-sidebar"
import { useGraphicsPresetDetection } from "@/hooks/use-graphics-preset-detection"

export function ShaderLabPage() {
useGraphicsPresetDetection()

return (
<main
id="main-content"
Expand Down
19 changes: 18 additions & 1 deletion src/hooks/use-editor-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
} from "@/renderer/create-webgpu-renderer"
import { useAssetStore } from "@/store/asset-store"
import { useEditorStore } from "@/store/editor-store"
import {
getActivePresetSettings,
useGraphicsPresetStore,
} from "@/store/graphics-preset-store"
import { useLayerStore } from "@/store/layer-store"
import { useMetricsStore } from "@/store/metrics-store"
import { useTimelineStore } from "@/store/timeline-store"
Expand All @@ -18,7 +22,9 @@ function getPixelRatio(): number {
return 1
}

return Math.min(window.devicePixelRatio || 1, 2)
const preset = getActivePresetSettings()
const base = Math.min(window.devicePixelRatio || 1, preset.pixelRatioCap)
return Math.max(0.25, base * preset.renderScale)
}

function measureElement(element: HTMLElement): Size {
Expand Down Expand Up @@ -64,6 +70,7 @@ export function useEditorRenderer() {
let lastFrameTime = performance.now()
let previewTime = 0
let resizeObserver: ResizeObserver | null = null
let unsubscribePreset: (() => void) | null = null

editorStore.setWebGPUStatus("initializing")

Expand Down Expand Up @@ -112,6 +119,14 @@ export function useEditorRenderer() {

resizeObserver.observe(viewportElement)

unsubscribePreset = useGraphicsPresetStore.subscribe((state, prev) => {
if (state.mode === prev.mode && state.detected === prev.detected) {
return
}
const size = useEditorStore.getState().canvasSize
renderer.resize(size, getPixelRatio())
})

const renderFrame = async (now: number) => {
const layerState = useLayerStore.getState()
const assetState = useAssetStore.getState()
Expand Down Expand Up @@ -193,6 +208,8 @@ export function useEditorRenderer() {
resizeObserver.disconnect()
}

unsubscribePreset?.()

if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current)
}
Expand Down
53 changes: 53 additions & 0 deletions src/hooks/use-graphics-preset-detection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client"

import { useEffect } from "react"
import {
adjustPresetFromFps,
detectInitialPreset,
sampleMedianFps,
} from "@/lib/editor/graphics-preset-detection"
import { useGraphicsPresetStore } from "@/store/graphics-preset-store"
import { useMetricsStore } from "@/store/metrics-store"

export function useGraphicsPresetDetection() {
useEffect(() => {
const { hasDetected } = useGraphicsPresetStore.getState()

if (hasDetected) {
return
}

const controller = new AbortController()

async function runDetection() {
const initial = await detectInitialPreset()

if (controller.signal.aborted) {
return
}

useGraphicsPresetStore.getState().setDetected(initial)

const medianFps = await sampleMedianFps({
getFps: () => useMetricsStore.getState().fps,
signal: controller.signal,
})

if (controller.signal.aborted) {
return
}

const adjusted = adjustPresetFromFps(initial, medianFps)

if (adjusted !== initial) {
useGraphicsPresetStore.getState().setDetected(adjusted)
}
}

void runDetection()

return () => {
controller.abort()
}
}, [])
}
173 changes: 173 additions & 0 deletions src/lib/editor/graphics-preset-detection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { GraphicsPreset } from "@/lib/editor/graphics-preset"

interface AdapterSignal {
vendor: string
architecture: string
isFallback: boolean
}

async function probeAdapter(): Promise<AdapterSignal | null> {
if (typeof navigator === "undefined" || !("gpu" in navigator)) {
return null
}

try {
const adapter = await navigator.gpu.requestAdapter()

if (!adapter) {
return null
}

const info = (adapter as GPUAdapter & { info?: GPUAdapterInfo }).info
const isFallback =
(adapter as GPUAdapter & { isFallbackAdapter?: boolean })
.isFallbackAdapter ?? false
return {
vendor: (info?.vendor ?? "").toLowerCase(),
architecture: (info?.architecture ?? "").toLowerCase(),
isFallback,
}
} catch {
return null
}
}

function isMobileUserAgent(): boolean {
if (typeof navigator === "undefined") {
return false
}
return /iphone|ipad|ipod|android|mobile/i.test(navigator.userAgent)
}

function presetFromHardwareConcurrency(): GraphicsPreset {
if (typeof navigator === "undefined") {
return "balanced"
}
const cores = navigator.hardwareConcurrency ?? 4
if (cores >= 8) return "quality"
if (cores >= 4) return "balanced"
return "performance"
}

export async function detectInitialPreset(): Promise<GraphicsPreset> {
const adapter = await probeAdapter()

if (adapter?.isFallback) {
return "performance"
}

if (isMobileUserAgent()) {
return "balanced"
}

if (adapter) {
const { vendor, architecture } = adapter

if (vendor === "apple") {
return "quality"
}

if (vendor === "nvidia" || vendor === "amd") {
return "quality"
}

if (vendor === "intel") {
const isModernIntel = /arc|iris|xe/.test(architecture)
return isModernIntel ? "balanced" : "performance"
}
}

return presetFromHardwareConcurrency()
}

function downgrade(preset: GraphicsPreset): GraphicsPreset {
if (preset === "quality") return "balanced"
if (preset === "balanced") return "performance"
return "performance"
}

/**
* Reads stable FPS after a warm-up window and downgrades the preset if FPS is
* below an acceptable threshold. Returns the (possibly downgraded) preset.
*/
export function adjustPresetFromFps(
current: GraphicsPreset,
medianFps: number
): GraphicsPreset {
if (!Number.isFinite(medianFps) || medianFps <= 0) {
return current
}
if (medianFps < 45) {
return downgrade(current)
}
return current
}

export interface FpsProbeOptions {
getFps: () => number
warmupMs?: number
sampleMs?: number
signal?: AbortSignal
}

/**
* Samples FPS after a warm-up (to let shader compilation settle) and returns
* the median of the sampled values. Reads from the existing metrics-store FPS
* signal; no new telemetry is introduced.
*/
export async function sampleMedianFps({
getFps,
warmupMs = 500,
sampleMs = 1000,
signal,
}: FpsProbeOptions): Promise<number> {
await wait(warmupMs, signal)

const samples: number[] = []
const sampleInterval = 50
let elapsed = 0

while (elapsed < sampleMs) {
if (signal?.aborted) {
break
}
const fps = getFps()
if (fps > 0 && Number.isFinite(fps)) {
samples.push(fps)
}
await wait(sampleInterval, signal)
elapsed += sampleInterval
}

if (samples.length === 0) {
return 0
}

samples.sort((a, b) => a - b)
const mid = Math.floor(samples.length / 2)
if (samples.length % 2 === 0) {
return ((samples[mid - 1] ?? 0) + (samples[mid] ?? 0)) / 2
}
return samples[mid] ?? 0
}

function wait(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
resolve()
return
}
const timer = window.setTimeout(() => {
resolve()
}, ms)
signal?.addEventListener(
"abort",
() => {
window.clearTimeout(timer)
resolve()
},
{ once: true }
)
void reject
})
}
Loading
Loading