diff --git a/src/components/editor/scene-config-content.tsx b/src/components/editor/scene-config-content.tsx
index 42da57c..8908806 100644
--- a/src/components/editor/scene-config-content.tsx
+++ b/src/components/editor/scene-config-content.tsx
@@ -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"
@@ -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)]"
@@ -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 (
+
+
+
+
+
+ {detectedLabel
+ ? `Recommended for this device: ${detectedLabel}. Auto applies the detected recommendation.`
+ : "Detecting recommended settings for this deviceā¦"}
+
+
+ )
+}
+
export function SceneConfigContent() {
const sceneConfig = useEditorStore((state) => state.sceneConfig)
const updateSceneConfig = useEditorStore((state) => state.updateSceneConfig)
@@ -144,6 +199,8 @@ export function SceneConfigContent() {
+
+
{/* Color Adjustments */}
void) | null = null
editorStore.setWebGPUStatus("initializing")
@@ -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()
@@ -193,6 +208,8 @@ export function useEditorRenderer() {
resizeObserver.disconnect()
}
+ unsubscribePreset?.()
+
if (animationFrameRef.current !== null) {
window.cancelAnimationFrame(animationFrameRef.current)
}
diff --git a/src/hooks/use-graphics-preset-detection.ts b/src/hooks/use-graphics-preset-detection.ts
new file mode 100644
index 0000000..e4ba3e4
--- /dev/null
+++ b/src/hooks/use-graphics-preset-detection.ts
@@ -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()
+ }
+ }, [])
+}
diff --git a/src/lib/editor/graphics-preset-detection.ts b/src/lib/editor/graphics-preset-detection.ts
new file mode 100644
index 0000000..d1c9d1c
--- /dev/null
+++ b/src/lib/editor/graphics-preset-detection.ts
@@ -0,0 +1,173 @@
+import type { GraphicsPreset } from "@/lib/editor/graphics-preset"
+
+interface AdapterSignal {
+ vendor: string
+ architecture: string
+ isFallback: boolean
+}
+
+async function probeAdapter(): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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
+ })
+}
diff --git a/src/lib/editor/graphics-preset.ts b/src/lib/editor/graphics-preset.ts
new file mode 100644
index 0000000..afcf31b
--- /dev/null
+++ b/src/lib/editor/graphics-preset.ts
@@ -0,0 +1,50 @@
+export type GraphicsPreset = "performance" | "balanced" | "quality"
+
+export type GraphicsPresetMode = "auto" | GraphicsPreset
+
+export interface GraphicsPresetSettings {
+ pixelRatioCap: number
+ renderScale: 1 | 0.75 | 0.5
+ layerCaps?: Record>
+}
+
+export const GRAPHICS_PRESET_CONFIG: Record<
+ GraphicsPreset,
+ GraphicsPresetSettings
+> = {
+ performance: {
+ pixelRatioCap: 1,
+ renderScale: 0.5,
+ },
+ balanced: {
+ pixelRatioCap: 1.5,
+ renderScale: 0.75,
+ },
+ quality: {
+ pixelRatioCap: 2,
+ renderScale: 1,
+ },
+}
+
+export const GRAPHICS_PRESET_LABELS: Record = {
+ performance: "Performance",
+ balanced: "Balanced",
+ quality: "Quality",
+}
+
+export function resolveActivePreset(
+ mode: GraphicsPresetMode,
+ detected: GraphicsPreset | null
+): GraphicsPreset {
+ if (mode === "auto") {
+ return detected ?? "balanced"
+ }
+ return mode
+}
+
+export function getPresetSettings(
+ mode: GraphicsPresetMode,
+ detected: GraphicsPreset | null
+): GraphicsPresetSettings {
+ return GRAPHICS_PRESET_CONFIG[resolveActivePreset(mode, detected)]
+}
diff --git a/src/renderer/create-webgpu-renderer.ts b/src/renderer/create-webgpu-renderer.ts
index 17cfe4f..4eeb092 100644
--- a/src/renderer/create-webgpu-renderer.ts
+++ b/src/renderer/create-webgpu-renderer.ts
@@ -19,7 +19,16 @@ export async function createWebGPURenderer(
function renderFrame(frame: RendererFrame) {
if (!pipeline) {
- pipeline = new PipelineManager(renderer, frame.viewportSize)
+ pipeline = new PipelineManager(renderer, {
+ width: Math.max(
+ 1,
+ Math.round(frame.viewportSize.width * frame.pixelRatio)
+ ),
+ height: Math.max(
+ 1,
+ Math.round(frame.viewportSize.height * frame.pixelRatio)
+ ),
+ })
}
pipeline.updateLogicalSize(frame.logicalSize)
@@ -66,7 +75,10 @@ export async function createWebGPURenderer(
resize(size: Size, pixelRatio: number) {
renderer.setPixelRatio(pixelRatio)
renderer.setSize(size.width, size.height, false)
- pipeline?.resize(size)
+ pipeline?.resize({
+ width: Math.max(1, Math.round(size.width * pixelRatio)),
+ height: Math.max(1, Math.round(size.height * pixelRatio)),
+ })
},
render(frame: RendererFrame) {
diff --git a/src/store/editor-store.ts b/src/store/editor-store.ts
index 8eb63bd..90a3be4 100644
--- a/src/store/editor-store.ts
+++ b/src/store/editor-store.ts
@@ -5,7 +5,6 @@ import type { EditorRenderer } from "@/renderer/contracts"
import type {
EditorStateSnapshot,
MobileEditorPanel,
- RenderScale,
SceneConfig,
SidebarView,
WebGPUStatus,
@@ -63,7 +62,6 @@ export interface EditorStoreActions {
setImmersiveCanvas: (immersiveCanvas: boolean) => void
setOutputSize: (width: number, height: number) => void
setPan: (x: number, y: number) => void
- setRenderScale: (scale: RenderScale) => void
setSidebarOpen: (side: "left" | "right", open: boolean) => void
setTheme: (theme: "dark" | "light") => void
setTimelineAutoKey: (enabled: boolean) => void
@@ -114,7 +112,6 @@ export const useEditorStore = create((set) => ({
mobilePanel: "none",
outputSize: DEFAULT_PROJECT_COMPOSITION,
panOffset: { x: 0, y: 0 },
- renderScale: 1,
sceneConfig: DEFAULT_SCENE_CONFIG,
sidebars: {
left: true,
@@ -261,12 +258,6 @@ export const useEditorStore = create((set) => ({
})
},
- setRenderScale: (renderScale) => {
- set({
- renderScale,
- })
- },
-
setImmersiveCanvas: (immersiveCanvas) => {
set((state) => ({
immersiveCanvas,
diff --git a/src/store/graphics-preset-store.ts b/src/store/graphics-preset-store.ts
new file mode 100644
index 0000000..a9b8c60
--- /dev/null
+++ b/src/store/graphics-preset-store.ts
@@ -0,0 +1,62 @@
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+import type {
+ GraphicsPreset,
+ GraphicsPresetMode,
+ GraphicsPresetSettings,
+} from "@/lib/editor/graphics-preset"
+import { getPresetSettings } from "@/lib/editor/graphics-preset"
+
+interface GraphicsPresetState {
+ mode: GraphicsPresetMode
+ detected: GraphicsPreset | null
+ hasDetected: boolean
+}
+
+interface GraphicsPresetActions {
+ setMode: (mode: GraphicsPresetMode) => void
+ setDetected: (detected: GraphicsPreset) => void
+ markDetected: () => void
+ resetDetection: () => void
+}
+
+export type GraphicsPresetStore = GraphicsPresetState & GraphicsPresetActions
+
+export const useGraphicsPresetStore = create()(
+ persist(
+ (set) => ({
+ mode: "auto",
+ detected: null,
+ hasDetected: false,
+
+ setMode: (mode) => {
+ set({ mode })
+ },
+
+ setDetected: (detected) => {
+ set({ detected, hasDetected: true })
+ },
+
+ markDetected: () => {
+ set({ hasDetected: true })
+ },
+
+ resetDetection: () => {
+ set({ detected: null, hasDetected: false })
+ },
+ }),
+ {
+ name: "shader-lab:graphics-preset",
+ partialize: (state) => ({
+ mode: state.mode,
+ detected: state.detected,
+ hasDetected: state.hasDetected,
+ }),
+ }
+ )
+)
+
+export function getActivePresetSettings(): GraphicsPresetSettings {
+ const { mode, detected } = useGraphicsPresetStore.getState()
+ return getPresetSettings(mode, detected)
+}
diff --git a/src/types/editor.ts b/src/types/editor.ts
index 462080e..71a3df9 100644
--- a/src/types/editor.ts
+++ b/src/types/editor.ts
@@ -445,7 +445,6 @@ export const DEFAULT_SCENE_CONFIG: SceneConfig = {
colorMap: null,
}
-export type RenderScale = 1 | 0.75 | 0.5
export type WebGPUStatus =
| "idle"
| "unsupported"
@@ -459,7 +458,6 @@ export interface EditorStateSnapshot {
interactiveEditDepth: number
outputSize: Size
panOffset: Vector2
- renderScale: RenderScale
sceneConfig: SceneConfig
sidebars: {
left: boolean