diff --git a/docs/PERFORMANCE-HANDOFF.md b/docs/PERFORMANCE-HANDOFF.md index d60545b..8304ee5 100644 --- a/docs/PERFORMANCE-HANDOFF.md +++ b/docs/PERFORMANCE-HANDOFF.md @@ -1,6 +1,6 @@ # Performance investigation — OPEN -Updated 2026-09-10. **Do not treat the Godot performance problem as resolved.** +Updated 2026-09-11. **Do not treat the Godot performance problem as resolved.** The development laptop experienced severe system-wide slowdowns, initially near the first fab and later near the second. The user still reported excessive @@ -68,3 +68,23 @@ workflow are tracked; generated engine binaries and web exports are not. The games have separate browser-local saves. Changing browser, machine, or origin does not transfer progress automatically. Git transfers the project, not a running game's browser storage. + + +## Cross-device report and graphics controls — 2026-09-11 + +The user confirms remaining slowdown as the factory scales on both the development +laptop and a phone, without a consistent two-fab threshold. The earlier smoother +hosted run did not establish a fix. Browser versions and phone model are unknown. + +The lost edge smoothing corresponds to disabling MSAA during the earlier +performance investigation. New graphics controls expose off/2×/4× MSAA, +1280×800 / 1440×900 / 1920×1200 resolution caps, and shadows. Defaults retain the +previous rendering budget. Preferences are independent of simulation saves. + +An opt-in, bounded 60-sample local JSON recording reports actual settings, fab +count, frame intervals, rendering counters and node count. Optional JS heap is +approximate and excludes total browser/GPU/system memory. Use identical camera, +resolution and sound when comparing fab counts; then vary AA and shadows +separately. CI software rendering can validate controls and bounded allocations, +but cannot establish performance on the affected laptop or phone. No local game, +browser or server should be launched to test these changes. diff --git a/godot/README.md b/godot/README.md index 9ff7957..940efe8 100644 --- a/godot/README.md +++ b/godot/README.md @@ -52,7 +52,7 @@ are retained. On other platforms, install the matching editor and set | New Run | Confirm and reset this prototype's save | Touch users can use the action dock and drag the scene. Each etch consumes one -wafer and pays $100 when finished. Twelve chips finance the first fab. Reserve +wafer and pays $100 when finished. New runs start with $500; seven chips finance the first fab. Existing saves retain their balance. Reserve $600 for a shipment of 30 wafers. If you run out of both silicon and money, procurement offers three reclaimed wafers so the run cannot become stranded. @@ -73,7 +73,7 @@ purchasing and production reuse those objects. Machine monitors show cycle state instead of rebuilding percentage text throughout every cycle. Rendering is capped at 30 fps and at a 1440 × 900 internal viewport (preserving -aspect ratio on smaller or portrait screens). MSAA and the full-screen mipmap +aspect ratio on smaller or portrait screens). By default MSAA and the full-screen mipmap bloom pass are disabled to reduce GPU load. This trades some edge sharpness and glow for a lower rendering budget on laptops. The simulation still advances by elapsed time; the cap does not halve production speed. @@ -167,3 +167,23 @@ The performance investigation remains open. Version 2 saves keep the original `the-seed-v1.json` location and accept version 1 factory saves. The browser regression fixture is enabled only by `?test=1&scenario=chorus`; normal play exposes no state setter. + + +## Graphics and performance recording + +Open **Graphics / controls** below the game to select off, 2× or 4× MSAA, +change the internal resolution cap, or toggle shadows. These preferences persist +separately from your run. Defaults retain the previous budget: AA off, 1440 × 900 +maximum, shadows on, 30 fps. Try 2× MSAA for smoother geometry; higher resolution +and 4× MSAA cost more GPU work. Bloom remains disabled. + +The same panel can record up to 60 one-second samples while you play, then +download a local JSON report. It includes fab count, settings, frame intervals, +draw calls, rendered primitives, node count, and approximate JavaScript heap +where the browser exposes it. Frame intervals include the frame limiter; +JavaScript heap is **not** total browser, GPU, or system memory. Recording stops +when the tab is hidden. Nothing is uploaded. The scaling problem remains open. + +Remote browser checks use `?test=1&scenario=scaling` (one funded, muted fab), +then real build controls to compare two and six fabs and record AA/shadow settings. +This fixture is available only in test mode; it does not replace normal saves. diff --git a/godot/scripts/factory.gd b/godot/scripts/factory.gd index e4a14d2..1727d9f 100644 --- a/godot/scripts/factory.gd +++ b/godot/scripts/factory.gd @@ -41,6 +41,11 @@ var selected_ring: MeshInstance3D var debug_timer: float = 0.0 var fps_samples: int = 0 var reset_pending: bool = false +var render_cap: int = 1440 +var graphics_timer: float = 0.0 +var sample_time: float = 0.0 +var sample_intervals: Array[float] = [] +var was_recording: bool = false func _ready() -> void: Engine.max_fps=30 @@ -60,6 +65,8 @@ func _ready() -> void: sim.restore({"version":1, "capital":60000, "wafers":300, "chips":1200, "fabs":6, "overclock":true, "controller":true, "linked":true, "sound_enabled":false}) if test_mode and OS.has_feature("web") and str(JavaScriptBridge.get_interface("window").location.search).contains("scenario=firstlight"): sim.restore({"version":2, "capital":30000, "wafers":300, "chips":2000, "fabs":6, "overclock":true, "controller":true, "linked":true, "sound_enabled":false, "places":[7,1,0], "signals":1000, "resonance":1500.0, "charter":0}) + if test_mode and OS.has_feature("web") and str(JavaScriptBridge.get_interface("window").location.search).contains("scenario=scaling"): + sim.restore({"version":1, "capital":60000, "wafers":300, "chips":0, "fabs":1, "sound_enabled":false}) _create_environment() room=Chamber.new() add_child(room) @@ -80,6 +87,7 @@ func _ready() -> void: camera_focus=sim.fabs==0 _create_camera() _create_audio() + _update_graphics(1.0) ui=Interface.new() add_child(ui) ui.action_requested.connect(_action) @@ -141,11 +149,53 @@ func _limit_render_size() -> void: if window.size==last_window_size:return last_window_size=window.size var physical:=Vector2(window.size) - var factor:=minf(1.0,minf(1440.0/maxf(1,physical.x),900.0/maxf(1,physical.y))) + var factor:=minf(1.0,minf(float(render_cap)/maxf(1,physical.x),float(render_cap)*0.625/maxf(1,physical.y))) var target:=Vector2i((physical*factor).round()) window.content_scale_mode=Window.CONTENT_SCALE_MODE_VIEWPORT if window.content_scale_size!=target:window.content_scale_size=target +func _update_graphics(delta: float) -> void: + if not OS.has_feature("web"):return + var browser_window = JavaScriptBridge.get_interface("window") + var settings = browser_window.seedGraphics + if settings == null:return + graphics_timer += delta + if graphics_timer >= 0.5: + graphics_timer = 0.0 + var aa: int = clampi(int(settings.aa), 0, 2) + if int(get_viewport().msaa_3d) != aa:get_viewport().msaa_3d = aa as Viewport.MSAA + var cap: int = int(settings.cap) + if cap in [1280, 1440, 1920] and render_cap != cap: + render_cap = cap + last_window_size = Vector2i.ZERO + if key_light.shadow_enabled != bool(settings.shadows):key_light.shadow_enabled = bool(settings.shadows) + var recording: bool = bool(settings.recording) + if recording and not was_recording: + sample_time = 0.0 + sample_intervals.clear() + was_recording = recording + if not recording:return + sample_time += delta + # Bound recording storage even if the frame cap is changed later. + if sample_intervals.size() < 240:sample_intervals.append(delta * 1000.0) + if sample_time < 1.0:return + sample_intervals.sort() + var sample: Dictionary = { + "fps": Engine.get_frames_per_second(), "window_seconds": sample_time, + "frame_interval_p95_ms": sample_intervals[mini(sample_intervals.size()-1, floori(sample_intervals.size()*0.95))], + "frame_interval_max_ms": sample_intervals.back(), + "render_width": get_viewport().get_visible_rect().size.x, + "render_height": get_viewport().get_visible_rect().size.y, + "msaa": int(get_viewport().msaa_3d), "shadows": key_light.shadow_enabled, + "fabs": sim.fabs, "chips": sim.chips, "sound_enabled": sim.sound_enabled, + "atlas": atlas, "nodes": get_tree().get_node_count(), + "draw_calls": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME), + "rendered_primitives": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME), + } + browser_window.seedReceivePerformance(JavaScriptBridge.get_interface("JSON").parse(JSON.stringify(sample))) + sample_time = 0.0 + sample_intervals.clear() + func _create_audio() -> void: for id in ["etch","chip","build","supply","uplink"]: sounds[id]=load("res://assets/audio/"+id+".wav") @@ -170,6 +220,7 @@ func _sound(id: String, volume: float=-15.0) -> void: voice.play() func _process(delta: float) -> void: + _update_graphics(delta) _limit_render_size() # A short fixed step keeps production deterministic across render frame rates. # Browser tab suspension does not mint unobserved chips in this prototype. @@ -210,6 +261,8 @@ func _process(delta: float) -> void: snapshot["nodes"]=get_tree().get_node_count() snapshot["render_width"]=get_viewport().get_visible_rect().size.x snapshot["render_height"]=get_viewport().get_visible_rect().size.y + snapshot["msaa"]=get_viewport().msaa_3d + snapshot["shadows"]=key_light.shadow_enabled snapshot["atlas"]=atlas snapshot["cinema"]=ui.focus_mode snapshot["camera_focus"]=camera_focus @@ -292,7 +345,7 @@ func _handle_events() -> void: _sound("chip",-23 if index>=0 else -16) production_chime=0.18 if sim.chips==1:ui.notify("First chip shipped. $100. A very small beginning.",true) - elif sim.chips==12 and sim.fabs==0:ui.notify("Your first fab is ready to fund. Press B or choose Install a fab.",true) + elif sim.chips==7 and sim.fabs==0:ui.notify("Your first fab is ready to fund. Press B or choose Install a fab.",true) "build": _add_machine(int(event.machine)) camera_focus=false diff --git a/godot/scripts/interface.gd b/godot/scripts/interface.gd index 3830e53..20f3ebe 100644 --- a/godot/scripts/interface.gd +++ b/godot/scripts/interface.gd @@ -101,7 +101,7 @@ func _ready() -> void: chapter_box.add_child(_label("O B J E C T I V E / 0 1",10,GOLD)) chapter_title=_label("Build the machine\nthat builds the machine.",22,WHITE) chapter_box.add_child(chapter_title) - chapter_note=_label("Etch 12 chips. Sales are automatic.\nThen install your first autonomous fab.",12,MUTED) + chapter_note=_label("Etch 7 chips. Sales are automatic.\nThen install your first autonomous fab.",12,MUTED) chapter_note.autowrap_mode=TextServer.AUTOWRAP_WORD_SMART chapter_box.add_child(chapter_note) var sep:=HSeparator.new() @@ -121,7 +121,7 @@ func _ready() -> void: fill.content_margin_bottom=0 objective_progress.add_theme_stylebox_override("fill",fill) chapter_box.add_child(objective_progress) - objective_numbers=_label("$0 / $1,200",11,MUTED) + objective_numbers=_label("$500 / $1,200",11,MUTED) chapter_box.add_child(objective_numbers) chapter_box.add_child(_label("S Y S T E M T R A N S M I S S I O N",9,MUTED)) journal=_label("The room is quiet.\nThat part is temporary.",13,WHITE) diff --git a/godot/scripts/simulation.gd b/godot/scripts/simulation.gd index 02cceff..d2f6ecb 100644 --- a/godot/scripts/simulation.gd +++ b/godot/scripts/simulation.gd @@ -12,7 +12,7 @@ const OVERCLOCK_COST: int = 2400 const UPLINK_COST: int = 6000 const SAVE_VERSION: int = 2 -var capital: int = 0 +var capital: int = 500 var wafers: int = 60 var chips: int = 0 var fabs: int = 0 diff --git a/godot/tests/test_simulation.gd b/godot/tests/test_simulation.gd index 47df7f0..08fac49 100644 --- a/godot/tests/test_simulation.gd +++ b/godot/tests/test_simulation.gd @@ -19,11 +19,11 @@ func _initialize() -> void: check(sim.wafers==59 and sim.chips==0,"Starting a cycle consumes one wafer but grants no output early") check(not sim.etch(),"Manual cycle cannot be double queued") advance(sim,1.0) - check(sim.chips==1 and sim.capital==100,"Completed fabrication sells one chip for $100") - for i in 11: + check(sim.chips==1 and sim.capital==600,"New runs start with $500 and a completed chip adds $100") + for i in 6: sim.etch() advance(sim,1) - check(sim.capital==1200,"Twelve manual chips finance the first machine") + check(sim.capital==1200,"Seven manual chips plus starting capital finance the first machine") check(sim.build_fab() and sim.fabs==1 and sim.capital==0,"The first fab debits the displayed price") var before: int=sim.chips advance(sim,3.5) diff --git a/godot/web/boot.js b/godot/web/boot.js index eaa718e..c1fe10a 100644 --- a/godot/web/boot.js +++ b/godot/web/boot.js @@ -5,6 +5,70 @@ const loading = document.getElementById('loading'); const status = document.getElementById('status'); const retry = document.getElementById('retry'); const controls = document.getElementById('controls'); +// Preferences do not touch the simulation save or silently raise the GPU budget. +const graphicsKey = 'the-seed-graphics-v1'; +let preferences = {}; +try { preferences = JSON.parse(localStorage.getItem(graphicsKey) || '{}') || {}; } catch {} +window.seedGraphics = { + aa: [0, 1, 2].includes(preferences.aa) ? preferences.aa : 0, + cap: [1280, 1440, 1920].includes(preferences.cap) ? preferences.cap : 1440, + shadows: typeof preferences.shadows === 'boolean' ? preferences.shadows : true, + recording: false, +}; +const aaControl = document.getElementById('graphics-aa'); +const capControl = document.getElementById('graphics-cap'); +const shadowControl = document.getElementById('graphics-shadows'); +aaControl.value = String(window.seedGraphics.aa); +capControl.value = String(window.seedGraphics.cap); +shadowControl.checked = window.seedGraphics.shadows; +for (const control of [aaControl, capControl, shadowControl]) control.addEventListener('change', () => { + Object.assign(window.seedGraphics, { aa: Number(aaControl.value), cap: Number(capControl.value), shadows: shadowControl.checked }); + try { + localStorage.setItem(graphicsKey, JSON.stringify({ aa: window.seedGraphics.aa, cap: window.seedGraphics.cap, shadows: window.seedGraphics.shadows })); + document.getElementById('graphics-storage').textContent = 'Settings saved.'; + } catch { document.getElementById('graphics-storage').textContent = 'Storage unavailable: settings apply to this session only.'; } +}); +const recordButton = document.getElementById('record-performance'); +const downloadButton = document.getElementById('download-performance'); +const recordingStatus = document.getElementById('performance-status'); +let samples = []; +let recordingStarted = null; +function stopRecording() { + window.seedGraphics.recording = false; + recordButton.textContent = 'Record 60 seconds'; + downloadButton.disabled = samples.length === 0; + recordingStatus.textContent = `${samples.length} samples recorded. Report stays on this machine.`; +} +recordButton.addEventListener('click', () => { + if (window.seedGraphics.recording) { stopRecording(); return; } + samples = []; + recordingStarted = new Date().toISOString(); + window.seedGraphics.recording = true; + recordButton.textContent = 'Stop recording'; + downloadButton.disabled = true; + recordingStatus.textContent = 'Recording while you play. You can close this panel.'; +}); +// Called once per measured second by Godot, only during an explicit recording. +window.seedReceivePerformance = sample => { + if (!window.seedGraphics.recording || samples.length >= 60) return; + const heap = performance.memory; + samples.push({ ...sample, js_heap_bytes_approx: heap ? heap.usedJSHeapSize : null }); + recordingStatus.textContent = `${samples.length}/60 samples · ${sample.fps} fps · ${sample.render_width} × ${sample.render_height} · AA ${['off', '2×', '4×'][sample.msaa]}`; + if (samples.length >= 60) stopRecording(); +}; +document.addEventListener('visibilitychange', () => { + // Do not mix background throttling into a foreground comparison. + if (document.hidden && window.seedGraphics.recording) stopRecording(); +}); +downloadButton.addEventListener('click', () => { + const report = { schema: 1, started_at: recordingStarted, browser: navigator.userAgent, + device_pixel_ratio: devicePixelRatio, screen: { width: screen.width, height: screen.height }, + limitations: 'Frame intervals include the 30 fps limiter. JavaScript heap is approximate and excludes total browser/GPU/system memory. No performance-fix conclusion is implied.', samples }; + const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' })); + const link = document.createElement('a'); + link.href = url; link.download = 'the-seed-performance.json'; link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +}); if (location.port === '4180' && ['localhost', '127.0.0.1'].includes(location.hostname)) { document.getElementById('versions-link').href = 'http://localhost:3000/'; } @@ -32,6 +96,6 @@ if (typeof Engine === 'undefined') { document.getElementById('bar').style.width = `${current / total * 100}%`; status.textContent = current < total ? `DELIVERING THE MACHINERY · ${Math.round(current / total * 100)}%` : 'WARMING UP THE FABRICATION FLOOR'; } - } }).then(() => { loading.remove(); canvas.focus(); }, failure); + } }).then(() => { loading.remove(); canvas.focus(); recordButton.disabled = false; recordingStatus.textContent = 'Ready to record.'; }, failure); } } diff --git a/godot/web/shell.html b/godot/web/shell.html index 9948412..7d1b1f3 100644 --- a/godot/web/shell.html +++ b/godot/web/shell.html @@ -8,7 +8,7 @@
A quiet room. A silicon wafer.
An objective that doesn't know when to stop.