diff --git a/skills/optimize-audio/SKILL.md b/skills/optimize-audio/SKILL.md new file mode 100644 index 0000000..8c6a5a0 --- /dev/null +++ b/skills/optimize-audio/SKILL.md @@ -0,0 +1,199 @@ +--- +name: optimize-audio +description: Optimizes Unity 6 audio memory, CPU cost, and playback quality through correct import settings and mixer configuration. Use when the user wants to reduce audio memory usage, choose the right Load Type for short clips versus music versus ambient beds, configure platform-appropriate sample rates and codecs, force 3D audio to mono, or reduce AudioMixer CPU cost from deep group trees or effects running on silent paths. +--- +## Critical Rules + +- Do not make changes before reporting findings to the user +- Follow steps in strict order; never jump ahead +- STOP at every `WAIT` checkpoint and await the user's response before continuing +- Quality is more important than speed: measure before and after every change +- Always verify results in a device build; Editor audio stats are indicative only + +## 0. Set up the execution path + +Every C# step below runs inside a live Editor through the Unity CLI. **The `unity-cli` skill owns +getting you there** — installing the CLI, confirming a connected Editor, adding the project's +`com.unity.pipeline` package, telling a genuinely absent Editor apart from one stuck in Safe Mode, +and discovering the Editor's command catalog. Follow it first; don't re-derive any of it here. + +Two things it can't know for you: + +- **You need `eval` in particular**, not just a reachable Editor. Confirm it appears in the + catalog. Its presence depends on the Pipeline package version, not on the CLI, so a healthy + install can still lack it — if it's missing, say so and stop. +- **Do not hand-edit `.meta` files to change import settings.** Importer values only take effect + through `SaveAndReimport()` in a live Editor, so an unreachable Editor is a stop, not a cue to + edit metadata directly. + +Run C# with `unity command eval --code ''`. Discover the parameter shape from +`unity command --format json` rather than assuming one. `unity command` defaults to a 30 second +timeout. + +### Passing C# to `eval` + +`eval` compiles a **statement block, not a file**. Two consequences, both of which cause a compile +error rather than a warning: + +- **No `using` directives.** The compiler reads `using UnityEngine;` as a resource-disposal + statement and rejects it (`CS0210`). +- **Types must be fully qualified.** A bare `AssetDatabase` or `AudioImporter` does not resolve + (`CS0246` / `CS0103`), and a bare `Object` is ambiguous with `object` (`CS0104`). + +The recipes in [resources/audio-import-api.md](resources/audio-import-api.md) are written +fully qualified so they can be passed to `eval` as-is. + +## 1. Pre-Flight: Detect Audio System + +Before doing anything else, establish the audio environment: + +1. **Detect platform and sample rate:** Use `eval` to read `EditorUserBuildSettings.activeBuildTarget` and `AudioSettings.outputSampleRate`. The output sample rate affects whether overriding clip sample rates will actually save memory. +2. **Detect AudioMixer presence:** Use the mixer-asset query recipe in [resources/audio-import-api.md](resources/audio-import-api.md) to see if a mixer graph exists. If none exists, note that routing and effect costs are not a concern. +3. **Detect AudioListener:** Use the scene-component query recipe in [resources/audio-import-api.md](resources/audio-import-api.md) for `UnityEngine.AudioListener` to confirm exactly one listener is present. Multiple listeners produce incorrect spatialization; zero listeners produce silence. +4. **Proceed** only after platform and listener state are confirmed. + +## 2. Assess Current State + +Before recommending any change, gather observable data: + +1. **Find all AudioSources:** Use the scene-component query recipe in [resources/audio-import-api.md](resources/audio-import-api.md) for `UnityEngine.AudioSource`. For each result, use **one** `eval` call to batch-read properties — see the batch read recipe in [resources/audio-import-api.md](resources/audio-import-api.md). +2. **Inspect mixer topology:** If a mixer was found in Pre-Flight, use `eval` to read the AudioMixer's exposed parameters and group count. A group count above ~8 or effects on the Master group are immediate flags. +3. **Check DSP buffer size:** Use the DSP buffer recipe in [resources/audio-import-api.md](resources/audio-import-api.md) to read buffer size. See DSP Buffer Size Guidelines in [resources/platform-settings.md](resources/platform-settings.md) for recommended values. +4. **Report findings before making changes:** Summarize ALL detected sources, the listener count, and mixer depth to the user. Flag any immediate risks (e.g., stereo clip with `spatialBlend = 1`, Decompress On Load on a clip > 1 MB, reverb on the Master group). + +**WAIT for the user to review the assessment before proceeding.** + +## 3. Understand Request + +Route to the correct section based on what the user needs: + +| User Says | Path | +|-----------|------| +| "audio memory too high" / "memory profiler shows audio" | Section 4 — Import settings audit | +| "load times slow" / "decompression stall" | Section 4 — Load Type review | +| "DSP spike" / "mixer CPU" / "audio CPU high" | Section 4B — Mixer audit | +| "3D sound wrong" / "only left channel plays" / "stereo in 3D" | Section 4A — Force To Mono + spatial settings | +| "quality artifacts" / "voice sounds bad" / "Vorbis crackling" | Section 4C — Compression quality tuning | +| "mobile audio battery" / "mobile memory" | Section 4D — Mobile sample rate override | +| "set import settings on all clips" / "batch audio settings" | Section 4 — Bulk import audit | +| "streaming" / "background loading" / "Addressables audio" | Section 4E — Streaming and async load | + +If the symptom is ambiguous, ask: "Is the problem audio memory usage, DSP CPU spikes, or audio playback quality?" + +## 4. Primary Diagnostic Workflow + +Use the findings from Section 2 to determine which sub-section applies. More than one may apply simultaneously. + +### 4A. Force To Mono and Spatial Settings + +For any AudioSource where `spatialBlend > 0` (3D positioned sound): + +1. **Check clip channel count:** Use `eval` to read `audioSource.clip.channels`. If `channels == 2` and `spatialBlend == 1`, only the left channel plays — this is a bug, not a feature. +2. **Recommend Force To Mono:** Use the read importer recipe in [resources/audio-import-api.md](resources/audio-import-api.md) to inspect current settings, then apply Force To Mono using the force-to-mono recipe. +3. **Apply and reimport:** Report before/after channel counts to the user. +4. **Verify spatial blend:** Use `eval` to confirm `audioSource.spatialBlend` is `1.0` (full 3D) and `audioSource.rolloffMode` is set to an appropriate curve. + +### 4B. AudioMixer Audit + +1. **Measure group depth:** Use `eval` to walk the mixer's group tree and count levels. More than 3 levels (Master → SFX / Music / Voice → sub-bus) adds routing overhead every frame, even when children are silent. +2. **Check effects on silent groups:** Use `eval` to query each group's effects list. Effects such as `AudioReverbFilter` run their DSP at full cost even when no AudioSource routes to that group. +3. **Flag SFX Reverb on parent groups:** This is the most expensive built-in effect. If found on the Master or a high-level group, flag it explicitly. +4. **Present recommendations to the user:** + - Remove or bypass effects on groups that have no active sources. + - Use **snapshots** to switch mix states (combat / explore / pause) rather than toggling effects at runtime. + - Flatten unnecessary sub-buses; redirect sources to a shallower ancestor. + + **WAIT for the user to approve the mixer changes before applying.** + +5. **Verify DSP buffer size:** If `bufferLength` from Pre-Flight is very small (< 256), recommend increasing it — see DSP Buffer Size Guidelines in [resources/platform-settings.md](resources/platform-settings.md). + +### 4C. Compression Quality Tuning + +1. **Read current compression format:** Use the read importer recipe in [resources/audio-import-api.md](resources/audio-import-api.md) to read `compressionFormat` and `quality` for the clips reported by the user. +2. **Apply the platform matrix:** See the Compression Format Matrix in [resources/platform-settings.md](resources/platform-settings.md) for per-platform recommendations. +3. **Warn about lossy sources:** Use the lossy source check recipe in [resources/audio-import-api.md](resources/audio-import-api.md). If the original file is MP3, warn the user that lossy source quality is lost permanently after Unity re-encodes. Recommend WAV or AIFF sources. + +### 4D. Mobile Sample Rate Override + +1. **Identify SFX clips on mobile target:** Use the scene-component query recipe for `UnityEngine.AudioSource` and filter for non-music, non-dialogue clips. +2. **Read current sample rate setting:** Use the read importer recipe in [resources/audio-import-api.md](resources/audio-import-api.md) to read `sampleRateSetting` and `sampleRateOverride` for each clip. +3. **Apply mobile override:** Use the sample rate override recipe in [resources/audio-import-api.md](resources/audio-import-api.md). See Sample Rate Recommendations in [resources/platform-settings.md](resources/platform-settings.md) for per-use-case rates. +4. **Report savings:** Halving the sample rate halves the PCM memory cost. Report the estimated saving for each clip changed. + +### 4E. Load Type and Streaming + +1. **Audit Load Type per clip:** Use `eval` to read `clip.loadType` for each clip found in Section 2. +2. **Apply the decision rule:** See Load Type Decision Table in [resources/platform-settings.md](resources/platform-settings.md). +3. **Flag mismatches:** See Load Type Mismatch Flags in [resources/platform-settings.md](resources/platform-settings.md). Report both types of mismatches to the user. +4. **Apply `Load In Background`** for any Streaming clip — use the Load In Background recipe in [resources/audio-import-api.md](resources/audio-import-api.md). + +## 5. Validation + +After any import setting or mixer change: + +1. **Re-read clip stats:** Use `eval` to re-read `clip.loadType`, `clip.channels`, `AudioSettings.outputSampleRate`, and the importer's `compressionFormat` to confirm the change applied after reimport. +2. **Confirm AudioSource routing:** Use the scene-component query recipe for `UnityEngine.AudioSource` and verify `audioSource.outputAudioMixerGroup` is assigned as expected after any mixer restructure. +3. **Report delta:** State the before and after values for each setting changed. Do not assume the change was effective without reading back the applied importer values. +4. **Iterate limit:** Maximum 3 adjust-and-verify cycles before pausing to ask the user for feedback. + +## 6. Troubleshooting + +### Stereo clip on a 3D AudioSource — only left channel audible + +1. Confirm `audioSource.spatialBlend == 1`. +2. Confirm `audioSource.clip.channels == 2`. +3. Enable `forceToMono` in the AudioClip importer and reimport. Unity mixes both channels to mono during import, preserving level with `normalize = true` (keep on). +4. If the user does not want to reimport: set `audioSource.panStereo = 0` as a runtime workaround, but warn this does not recover stereo information. + +### Decompress On Load clip causes memory spike + +1. Confirm `clip.loadType == AudioClipLoadType.DecompressOnLoad` and `clip.length` is long (> 5 s). +2. Switch to `Streaming` if it is music or ambience, `CompressedInMemory` if played only occasionally. +3. If the clip is short but still large: check `clip.channels` (stereo wastes double the memory) and `clip.frequency` (high sample rate on a mobile target wastes memory). Apply Force To Mono and/or sample rate override. + +### AudioMixer CPU spike — DSP thread hot + +1. Confirm with the mixer-asset query recipe that the mixer graph exists. +2. Use `eval` to list all groups and their attached effects. Look for reverb, chorus, or EQ on high-level groups. +3. Move expensive effects down to leaf groups that are only active when sources are playing. +4. Use snapshots to bypass effect chains during gameplay states where they are not heard (e.g., bypass reverb during a menu). +5. If the DSP buffer is small (64 or 128 samples), raise it — see DSP Buffer Size Guidelines in [resources/platform-settings.md](resources/platform-settings.md). + +### Vorbis quality artifacts on dialogue + +1. Confirm `defaultSampleSettings.compressionFormat == AudioCompressionFormat.Vorbis`. +2. Confirm `defaultSampleSettings.quality` — default is 0.5, which is often audible on voice. Raise to 0.7–0.85. +3. On iOS: switch to AAC instead of Vorbis (hardware decode, better quality at equivalent bitrate). +4. Confirm the source file is lossless (WAV or AIFF). MP3 sources cannot recover quality lost before Unity's re-encode. + +### AudioListener count is not exactly one + +- **Zero listeners:** All audio will be silent. Use `eval` to add an `AudioListener` component to the main camera: `UnityEngine.Camera.main.gameObject.AddComponent()`. +- **Multiple listeners:** Unity uses the last enabled one, producing unpredictable spatialization. Use the scene-component query recipe for `UnityEngine.AudioListener` and disable all but the intended one. + +### `Load In Background` causes first-play silence + +This is expected behavior: the clip has not finished loading when `Play()` is first called. Mitigate with: +1. Preload the clip at scene start by calling `clip.LoadAudioData()` before it is needed. +2. Use `AudioSource.PlayScheduled()` with a slight delay to allow async load to complete. +3. For AudioSources that must play immediately: switch to `CompressedInMemory` (synchronous on first play) rather than `Streaming` with background load. + +## 7. Completion + +After finishing the audit or optimization: + +- Summarize every setting changed with before/after values. +- List any clips or groups that still need attention (e.g., clips that require on-device measurement to confirm savings). +- If the user needs runtime memory measurement, point them at the Memory Profiler package, which reports the largest AudioClips by runtime byte cost. +- If mixer CPU is still high after the audit, point them at the Unity Profiler's Audio module for DSP thread profiling. + +## Detailed References + +- **Platform settings, compression matrix, load types, sample rates:** [resources/platform-settings.md](resources/platform-settings.md) +- **AudioImporter API recipes and code patterns:** [resources/audio-import-api.md](resources/audio-import-api.md) + +## See Also + +- **Memory Profiler package** — finds the largest AudioClips by runtime byte cost. +- **Unity Profiler, Audio module** — DSP CPU markers and frame-time budget. +- `audio-setup-mixers` — creating mixers and routing Audio Sources into groups. diff --git a/skills/optimize-audio/resources/audio-import-api.md b/skills/optimize-audio/resources/audio-import-api.md new file mode 100644 index 0000000..6be4b53 --- /dev/null +++ b/skills/optimize-audio/resources/audio-import-api.md @@ -0,0 +1,146 @@ +# Audio Import API Recipes + +C# code recipes for `unity command eval --code ''`. All examples target the Unity 6 +AudioImporter API. + +`eval` compiles a statement block, so there are no `using` directives and every type is written +fully qualified. Each recipe `return`s its result as a string rather than calling `Debug.Log`, so +the value comes back on the CLI's stdout instead of only reaching the Editor console. + +## Read AudioClip Importer Settings + +```csharp +var path = UnityEditor.AssetDatabase.GetAssetPath(audioSource.clip); +var importer = (UnityEditor.AudioImporter)UnityEditor.AssetImporter.GetAtPath(path); +return $"forceToMono={importer.forceToMono}, loadType={importer.defaultSampleSettings.loadType}, " + + $"compressionFormat={importer.defaultSampleSettings.compressionFormat}, " + + $"quality={importer.defaultSampleSettings.quality}, " + + $"sampleRateSetting={importer.defaultSampleSettings.sampleRateSetting}, " + + $"sampleRateOverride={importer.defaultSampleSettings.sampleRateOverride}"); +``` + +## Force To Mono and Reimport + +```csharp +var path = UnityEditor.AssetDatabase.GetAssetPath(audioSource.clip); +var importer = (UnityEditor.AudioImporter)UnityEditor.AssetImporter.GetAtPath(path); +importer.forceToMono = true; +importer.SaveAndReimport(); +return $"Reimported {path} — channels now: {audioSource.clip.channels}"); +``` + +## Set Load Type + +```csharp +var path = UnityEditor.AssetDatabase.GetAssetPath(audioSource.clip); +var importer = (UnityEditor.AudioImporter)UnityEditor.AssetImporter.GetAtPath(path); +var settings = importer.defaultSampleSettings; +settings.loadType = UnityEngine.AudioClipLoadType.Streaming; // or CompressedInMemory, DecompressOnLoad +importer.defaultSampleSettings = settings; +importer.SaveAndReimport(); +``` + +## Enable Load In Background + +```csharp +var path = UnityEditor.AssetDatabase.GetAssetPath(audioSource.clip); +var importer = (UnityEditor.AudioImporter)UnityEditor.AssetImporter.GetAtPath(path); +importer.loadInBackground = true; +importer.SaveAndReimport(); +``` + +## Set Compression Format and Quality + +```csharp +var path = UnityEditor.AssetDatabase.GetAssetPath(audioSource.clip); +var importer = (UnityEditor.AudioImporter)UnityEditor.AssetImporter.GetAtPath(path); +var settings = importer.defaultSampleSettings; +settings.compressionFormat = UnityEngine.AudioCompressionFormat.Vorbis; +settings.quality = 0.7f; // 0.0–1.0; raise to 0.7–0.85 for dialogue +importer.defaultSampleSettings = settings; +importer.SaveAndReimport(); +``` + +## Override Sample Rate (Mobile) + +```csharp +var path = UnityEditor.AssetDatabase.GetAssetPath(audioSource.clip); +var importer = (UnityEditor.AudioImporter)UnityEditor.AssetImporter.GetAtPath(path); +var settings = importer.defaultSampleSettings; +settings.sampleRateSetting = UnityEditor.AudioSampleRateSetting.OverrideSampleRate; +settings.sampleRateOverride = 22050u; +importer.defaultSampleSettings = settings; +importer.SaveAndReimport(); +``` + +## Read AudioSource Properties (Batch) + +Read multiple properties in a single `eval` call: + +```csharp +var src = audioSource; +return $"clip={src.clip?.name}, loadType={src.clip?.loadType}, " + + $"channels={src.clip?.channels}, frequency={src.clip?.frequency}, " + + $"spatialBlend={src.spatialBlend}, rolloff={src.rolloffMode}, " + + $"mixerGroup={src.outputAudioMixerGroup?.name ?? "None"}, " + + $"bypassEffects={src.bypassEffects}"); +``` + +## Read DSP Buffer Size + +```csharp +UnityEngine.AudioSettings.GetDSPBufferSize(out int bufferLength, out int numBuffers); +return $"DSP buffer: {bufferLength} samples x {numBuffers} buffers"); +``` + +## Check Source File Format (Lossy Warning) + +```csharp +var path = UnityEditor.AssetDatabase.GetAssetPath(clip); +if (path.EndsWith(".mp3", System.StringComparison.OrdinalIgnoreCase)) + return $"'{clip.name}' is MP3 — lossy source quality is lost permanently after Unity re-encodes. Recommend WAV or AIFF sources."); +``` + +## Resolving `audioSource` / `clip` inside a snippet + +The recipes above are written against an `audioSource` or `clip` variable. `eval` runs each +snippet in a fresh scope, so nothing carries over between calls — resolve the object at the top of +the same snippet that uses it. + +By scene object: + +```csharp +var sources = UnityEngine.Object.FindObjectsByType( + UnityEngine.FindObjectsInactive.Include, UnityEngine.FindObjectsSortMode.None); +var audioSource = System.Array.Find(sources, s => s.gameObject.name == "TheGameObjectName"); +``` + +By asset path, when you already know the clip: + +```csharp +var clip = UnityEditor.AssetDatabase.LoadAssetAtPath("Assets/Audio/Foo.wav"); +``` + +## Enumerate scene components + +Substitute the component type (`UnityEngine.AudioSource`, `UnityEngine.AudioListener`). Inactive +objects are included deliberately — a disabled second listener still counts against the +one-listener rule. + +```csharp +var found = UnityEngine.Object.FindObjectsByType( + UnityEngine.FindObjectsInactive.Include, UnityEngine.FindObjectsSortMode.None); +var names = System.Linq.Enumerable.Select(found, c => c.gameObject.name); +return $"count={found.Length}: {string.Join(", ", names)}"; +``` + +## Enumerate mixer assets + +An `AudioMixer` is a project asset, not a scene object, so it is found through the asset database +rather than a scene query. + +```csharp +var guids = UnityEditor.AssetDatabase.FindAssets("t:AudioMixer"); +var paths = System.Linq.Enumerable.Select(guids, UnityEditor.AssetDatabase.GUIDToAssetPath); +return $"count={guids.Length}: {string.Join(", ", paths)}"; +``` diff --git a/skills/optimize-audio/resources/platform-settings.md b/skills/optimize-audio/resources/platform-settings.md new file mode 100644 index 0000000..bf4b785 --- /dev/null +++ b/skills/optimize-audio/resources/platform-settings.md @@ -0,0 +1,48 @@ +# Audio Platform Settings Reference + +## Compression Format Matrix + +| Platform | Recommended Format | Notes | +|---|---|---| +| PC / cross-platform | Vorbis, quality 0.5–0.7 | Raise to 0.7–0.85 for dialogue; default 0.5 often adds artifacts | +| iOS | AAC | Hardware decode; cheapest CPU | +| Android | Vorbis | Software decode | +| Xbox | XMA | Use platform override in import settings | +| PlayStation | ATRAC9 | Use platform override in import settings | +| Web | Vorbis | Browser handles decode | + +## Sample Rate Recommendations + +| Use Case | Recommended Rate | +|---|---| +| PC / console music and voice | 44100 Hz | +| PC / console SFX | 44100 Hz | +| Mobile SFX | 22050 Hz | +| Mobile dialogue | 22050 or 44100 Hz | +| UI clicks / blips | 22050 Hz | + +Halving the sample rate halves the PCM memory cost. Always report the estimated saving for each clip changed. + +## Load Type Decision Table + +| Load Type | Behavior | Use For | +|---|---|---| +| Decompress On Load | PCM in memory at load; zero per-play CPU | Short SFX < 200 KB (uncompressed) | +| Compressed In Memory | Stays compressed; decompresses on play | Medium clips played occasionally | +| Streaming | Streams from disk; minimal RAM, higher disk I/O | Music, long ambience, voice-overs | + +### Load Type Mismatch Flags + +- **Decompress On Load** on a clip > 1 MB bloats memory. +- **Streaming** on a clip that plays dozens of times simultaneously adds disk pressure. +- Always apply `Load In Background` for any Streaming clip to prevent the main thread stalling on first play. + +## DSP Buffer Size Guidelines + +| Setting | Buffer Size | Use Case | +|---|---|---| +| Best Latency | 256 | Rhythm games, real-time synthesis | +| Good Latency | 512 | General gameplay | +| Best Performance | 1024 | Ambient/cinematic, battery-saving | + +A very small buffer (64 or 128) costs more CPU per frame. If `bufferLength` is < 256, recommend increasing to "Good Latency" or "Best Performance" to trade latency for CPU stability. diff --git a/skills/optimize-web/SKILL.md b/skills/optimize-web/SKILL.md new file mode 100644 index 0000000..ccfe96e --- /dev/null +++ b/skills/optimize-web/SKILL.md @@ -0,0 +1,393 @@ +--- +name: optimize-web +description: Optimizes Unity 6 WebGL and WebGPU builds for smaller download size, faster initial load, and efficient browser runtime performance. Use when the user's web build is too large, stutters in a specific browser, consumes excessive battery, needs CDN/server compression configured, or needs guidance on resource stripping, shader variant reduction, KTX textures, quality settings, or web profiling. +--- +## Performance Notes +- Take your time to do this thoroughly. +- Quality is more important than speed. + +## Running C# in the Editor + +Every step below that reads or writes a Player Setting runs inside a live Editor through the Unity +CLI. **The `unity-cli` skill owns getting you there** — installing the CLI, confirming a connected +Editor, adding the project's `com.unity.pipeline` package, telling a genuinely absent Editor apart +from one stuck in Safe Mode, and discovering the Editor's command catalog. Follow it first; don't +re-derive any of it here. + +Two things it can't know for you: + +- **You need `eval` in particular**, not just a reachable Editor. Confirm it appears in the catalog. + Its presence depends on the Pipeline package version, not on the CLI, so a healthy install can + still lack it — if it's missing, say so and stop. +- **Player Settings can be read from `ProjectSettings/ProjectSettings.asset` in a pinch, but do not + write them that way.** The serialized names don't match the API names, several of these settings + are per-build-target, and a hand-edited value silently disagrees with what the build actually + uses. An unreachable Editor is a stop for the write steps. + +Run C# with `unity command eval --code ''`. `unity command` defaults to a 30 second +timeout. + +### Passing C# to `eval` + +`eval` compiles a **statement block, not a file**. Two consequences, both compile errors rather than +warnings: + +- **No `using` directives.** The compiler reads `using UnityEditor;` as a resource-disposal + statement and rejects it (`CS0210`). +- **Types must be fully qualified.** A bare `PlayerSettings` does not resolve (`CS0246`), and a bare + `Object` is ambiguous with `object` (`CS0104`). + +### Reading the settings this skill audits + +One call returns the whole Pre-Flight picture. Verified against Unity 6000.5.7f1: + +```csharp +var target = UnityEditor.Build.NamedBuildTarget.WebGL; +var w = new System.Collections.Generic.List(); +w.Add($"activeBuildTarget={UnityEditor.EditorUserBuildSettings.activeBuildTarget}"); +w.Add($"compressionFormat={UnityEditor.PlayerSettings.WebGL.compressionFormat}"); +w.Add($"decompressionFallback={UnityEditor.PlayerSettings.WebGL.decompressionFallback}"); +w.Add($"stripEngineCode={UnityEditor.PlayerSettings.stripEngineCode}"); +w.Add($"managedStrippingLevel={UnityEditor.PlayerSettings.GetManagedStrippingLevel(target)}"); +w.Add($"il2cppCodeGeneration={UnityEditor.PlayerSettings.GetIl2CppCodeGeneration(target)}"); +w.Add($"apiCompatibilityLevel={UnityEditor.PlayerSettings.GetApiCompatibilityLevel(target)}"); +w.Add($"exceptionSupport={UnityEditor.PlayerSettings.WebGL.exceptionSupport}"); +w.Add($"debugSymbolMode={UnityEditor.PlayerSettings.WebGL.debugSymbolMode}"); +w.Add($"dataCaching={UnityEditor.PlayerSettings.WebGL.dataCaching}"); +w.Add($"wasm2023={UnityEditor.PlayerSettings.WebGL.wasm2023}"); +w.Add($"initialMemorySize={UnityEditor.PlayerSettings.WebGL.initialMemorySize}"); +w.Add($"maximumMemorySize={UnityEditor.PlayerSettings.WebGL.maximumMemorySize}"); +w.Add($"memoryGrowthMode={UnityEditor.PlayerSettings.WebGL.memoryGrowthMode}"); +w.Add($"targetFrameRate={UnityEngine.Application.targetFrameRate}"); +w.Add($"vSyncCount={UnityEngine.QualitySettings.vSyncCount}"); +return string.Join("\n", w); +``` + +**Three API names to get right**, because the obvious spellings do not exist and fail to compile: + +| Setting | Correct form | Does NOT exist | +|---|---|---| +| Managed stripping level | `PlayerSettings.GetManagedStrippingLevel(NamedBuildTarget.WebGL)` | `PlayerSettings.managedStrippingLevel` | +| Wasm code optimization | `UnityEditor.WebGL.UserBuildSettings.codeOptimization` | `PlayerSettings.WebGL.codeOptimization`, `PlayerSettings.WebGL.optimizationLevel` | +| IL2CPP code generation | `PlayerSettings.GetIl2CppCodeGeneration(NamedBuildTarget.WebGL)` | a bare property | + +`UserBuildSettings` lives in the WebGL build-support module, so it only resolves when that module is +installed. Read it in a separate call from the rest, and treat a resolution failure as "the Web +module isn't installed" rather than as a bad snippet. + +### Applying the settings + +Most of the writes in this skill are a single batch, and +[resources/WebOptimizer.cs](resources/WebOptimizer.cs) already is that batch. It declares a class +with a `[MenuItem]`, so it is a **project file, not `eval` input** — a class declaration cannot be +flattened into a statement block. Save it under `Assets/Editor/`, let Unity compile, then invoke it +in one line: + +```csharp +UnityEditor.EditorApplication.ExecuteMenuItem("Tools/Apply Web Release Settings"); +``` + +Keep its `using` directives; they are correct in a file. For one-off changes — a single quality +level, a frame-rate flip — an inline `eval` statement is fine. + +## 0. Pre-Flight + +1. **Confirm Web build target:** Read, with the Pre-Flight snippet above, `EditorUserBuildSettings.activeBuildTarget` — must be `WebGL`; if not, warn the user. +2. **Read compression and stripping settings:** Read `compressionFormat`, `decompressionFallback`, `stripEngineCode` and the managed stripping level with the Pre-Flight snippet above. Note the stripping level is `PlayerSettings.GetManagedStrippingLevel(NamedBuildTarget.WebGL)` — there is no `PlayerSettings.managedStrippingLevel` property. +3. **Read exception and optimization settings:** Read `PlayerSettings.WebGL.exceptionSupport` from the Pre-Flight snippet above. For the wasm code optimization level use `UnityEditor.WebGL.UserBuildSettings.codeOptimization` — the `PlayerSettings.WebGL.codeOptimization` and `optimizationLevel` spellings do not exist and will not compile. +4. **Read frame rate settings:** Read, with the Pre-Flight snippet above, `Application.targetFrameRate` and `QualitySettings.vSyncCount`. +5. **Read additional player settings:** Read, with the Pre-Flight snippet above, `PlayerSettings.WebGL.dataCaching`, `PlayerSettings.WebGL.debugSymbolMode`, `PlayerSettings.WebGL.maximumMemorySize`, and `PlayerSettings.GetApiCompatibilityLevel`. +6. Proceed only after compression, stripping, frame rate, and player settings are confirmed. + +## 1. Assess Current State + +1. **Check Build Report:** Instruct the user to open `Window > General > Build Report` after a build and identify the largest asset and code size contributors. +2. **Verify server configuration:** Ask the user to confirm whether the hosting server sends `Content-Encoding: br` (Brotli) or `Content-Encoding: gzip` headers, and whether `Content-Type: application/wasm` is set for `.wasm` files. +3. **Check frame rate config:** Confirm, with the Pre-Flight snippet above, `Application.targetFrameRate` — should be `-1` for Web (let the browser drive). +4. **Check memory settings:** Read, with the Pre-Flight snippet above, `PlayerSettings.WebGL.initialMemorySize` and `PlayerSettings.WebGL.memoryGrowthMode`. +5. Report findings before making recommendations. + +## 2. Understand Request + +| User Says | Default Interpretation | +|-----------|----------------------| +| "build too large" / "download too slow" | Strip Engine Code on; Managed Stripping High; Disk Size + LTO; Brotli | +| "Decompression Fallback" / "slow startup" | Decompression Fallback off; fix server to send Content-Encoding | +| "stutter in Chrome" / "stutter in Safari" | Profile in browser DevTools; Safari caps at 60 fps | +| "excessive battery in browser" | `OnDemandRendering` on static screens; `targetFrameRate = -1` | +| "exceptions too large" | None for release; Wasm 2023 exceptions if browser baseline allows | +| "set up CDN" | Addressables remote groups + Brotli/Gzip on CDN | +| "WebAssembly 2023" | Enable when browser baseline supports it — smaller and faster | +| "memory growth slow" | Tune Initial Memory Size to peak estimate; use Geometric growth mode | +| "KTX" / "Basis Universal" / "texture formats unknown GPU" | KTX2 with Basis Universal; ETC1S for size, UASTC for quality | +| "strip unused code" / "remove unused packages" | Web Stripping Tool + remove unused packages + shader stripping | +| "quality settings for web" | Quality Level to Very Low or Low; lower quality = faster load | +| "shader variants too many" | Graphics settings: auto lightmap/fog modes; strip instancing + BRG variants; audit Always Included Shaders | +| "video not playing" / "audio issues" | Video: URL-only or StreamingAssets; Audio: no AudioEffects on Web, use Mono, compress | +| "profiler symbols" / "can't read Wasm stacks" | Embed profiling symbols via build processor or emscriptenArgs | +| "iOS crashes" / "Safari memory" | iOS memory limits; set Initial Memory Size high rather than growing; Gigacage 2GB limit pre-iOS 18 | + +## 3. Web Build Optimization Workflow + +### IMPORTANT: One-click optimization script + +**Always offer to generate this script for the user.** Unity's official web optimization docs provide a single editor menu script that applies all recommended release settings at once. Place in `Assets/Editor/WebOptimizer.cs` — see [resources/WebOptimizer.cs](resources/WebOptimizer.cs) for the template. + +Adapt the script to the user's project needs (e.g. keep exceptions if they use `try/catch`, switch Brotli to Gzip for HTTP hosting). This script is the single most impactful action for a new web project — it prevents settings from being missed. + +### Player Settings audit + +Verify and set these values through `eval`: + +| Setting | Release recommendation | +|---|---| +| **Compression Format** | **Brotli** (HTTPS hosting); Gzip for HTTP | +| **Decompression Fallback** | **Off** when server is correctly configured | +| **Strip Engine Code** | **On** | +| **Managed Stripping Level** | **High** (release) / Medium (dev) | +| **Code Optimization** | **Disk Size with LTO** (release) / Build Times (dev) | +| **WebAssembly Language Features** | **2023** if browser baseline allows | +| **Enable Exceptions** | **None** (smallest); Explicitly Thrown Only if `try/catch` required | +| **Initial Memory Size** | Tune to peak estimate; too small causes expensive growth | +| **Memory Growth Mode** | **Geometric** | +| **API Compatibility Level** | **.NET Standard 2.1** — smaller than .NET Framework | +| **IL2CPP Code Generation** | **Optimize Size** — smaller Wasm at slight runtime cost | +| **Debug Symbols** | **Off** for release; on for development builds only | +| **Data Caching** | **On** — caches asset data in browser IndexedDB for faster repeat loads | +| **Strip Unused Mesh Components** | **On** — removes unused vertex attributes | +| **Maximum Memory Size** | **2048 MB** default; up to 4096 for complex 3D (Firefox and Chrome < 119 have issues above 2048) | +| **vSyncCount** | 0 (browser handles pacing) | +| **targetFrameRate** | -1 (use `requestAnimationFrame`) | + +### Compression and server configuration + +| Compression | Use when | Notes | +|---|---|---| +| **Brotli** | HTTPS or localhost | Best ratio; browsers accept only over secure contexts | +| **Gzip** | HTTP delivery, legacy CDNs | Universal | +| **None** | Local dev / file:// | Largest payload; do not ship | + +Configure the server to: +- Serve `.br` files with `Content-Encoding: br`. +- Serve `.gz` files with `Content-Encoding: gzip`. +- Set `Content-Type: application/wasm` for `.wasm`, `application/javascript` for `.js`. +- Enable HTTP/2 or HTTP/3 to parallelize chunk fetches. + +If the host cannot inject `Content-Encoding`: set **Decompression Fallback = On** as a fallback only — it adds ~150 KB JS and slows startup. + +### Exception handling + +| Setting | Build size | Use | +|---|---|---| +| **None** | Smallest | Release builds where uncaught exceptions are acceptable | +| **Explicitly Thrown Only** | Modest | Default for projects that catch exceptions | +| **Full** | Largest, slowest | Rarely needed; avoid for release | + +Wasm 2023 introduces a cheaper exception model; switching from Explicitly Thrown Only (legacy) to Wasm exceptions reduces both size and cost when browser targets support it. + +### Remove unused resources + +Three categories to audit for build size reduction: + +**1. Unused packages** — Check `Packages/manifest.json` and the Package Manager **In Project** and **Built-in** views. Remove or disable packages the project does not use. The Input System package is a significant size contributor if unused. + +**2. Shader stripping** — Configure in `Edit > Project Settings > Graphics`: + +| Setting | Recommendation | +|---|---| +| **Lightmap Modes** | Automatic (strips unused lightmap shader variants) | +| **Fog Modes** | Automatic (strips unused fog shader variants) | +| **Instancing Variants** | Strip Unused | +| **Batch Renderer Group Variants** | Strip All (if BRGs are not used) | +| **Always Included Shaders** | Audit and remove any shaders the project does not reference | + +Test after stripping — ensure no referenced shaders were removed. + +**3. Web Stripping Tool** (`com.unity.web.stripping-tool`) — Analyzes the WebAssembly binary and identifies unused Unity engine submodules (e.g. 3D graphics in a 2D-only game). Install via Package Manager, profile the build, then configure which submodules to exclude. Can yield substantial size reductions beyond what Managed Stripping Level achieves alone. + +### Quality settings for Web + +Lower quality levels reduce load time and improve runtime performance. Set via `Edit > Project Settings > Quality`: + +- Use **Very Low** or **Low** as the default Web quality level. +- Set it with `eval`: `UnityEngine.QualitySettings.SetQualityLevel(0, true);` where 0 = Very Low. +- Consider creating a Web-specific quality level that disables features unnecessary in-browser (real-time shadows, post-processing effects, high particle counts). + +### Frame rate on Web + +- Set it with `eval`: `UnityEngine.Application.targetFrameRate = -1;` — let the browser use `requestAnimationFrame`. +- Note: **Safari caps at 60 fps** in WebGL; high-refresh targets do not apply. +- Use `OnDemandRendering.renderFrameInterval` to drop to 5–10 fps on static/idle screens to save battery. + +### KTX / Basis Universal textures + +KTX2 with Basis Universal supercompression ships a single texture file that transcodes at load time to the optimal GPU format for the browser's device (BC7 on desktop, ASTC on mobile, ETC2 on older Android). This avoids shipping separate texture variants for each GPU family — critical for Web where the target hardware is unknown. + +| Topic | Guidance | +|---|---| +| **Package** | Install `com.unity.cloud.ktx` (KtxUnity) via Package Manager | +| **When to use** | Runtime-loaded textures via Addressables or asset bundles served to unknown GPU targets | +| **When NOT to use** | Textures baked into the player build — Unity already selects the correct format at build time | +| **Supercompression** | Use **ETC1S** for smallest size (lossy, good for diffuse/albedo); **UASTC** for higher quality (near-lossless, better for normals/UI) | +| **Encoding** | Encode offline with `toktx` or `basisu` CLI; do not encode at runtime | +| **Linear data** | Set `--assign_oetf linear` when encoding normal maps, masks, or data textures to avoid incorrect sRGB conversion | +| **Mip maps** | Generate mips at encode time (`--genmipmap`) — browser-side mip generation is expensive | +| **Loading** | Use `KtxTexture.LoadFromStreamingAssets` or load bytes via UnityWebRequest and call `KtxTexture.LoadFromBytes` | +| **Memory** | Transcoded textures are standard GPU textures; memory cost equals the target format, not the KTX2 file size | +| **Orientation** | Always include `--lower_left_maps_to_s0t0` to match Unity's UV convention | + +**`toktx` CLI examples:** See [resources/toktx-examples.sh](resources/toktx-examples.sh) for commands covering albedo (ETC1S), normals/detail (UASTC), ICC profile errors, and linear data. + +### Streaming on Web + +- Use Addressables with **remote groups** hosted on a CDN with Brotli / Gzip. +- Avoid bundling the entire game into the initial download; stream levels on demand. +- Target < 30 MB initial download for "instant play"; level data follows. +- For streamed textures targeting mixed GPU hardware, prefer KTX2 bundles over per-platform variants — one bundle serves all browsers. + +### Profiling Web builds + +| Tool | Use | Notes | +|---|---|---| +| **Chrome DevTools > Performance** | CPU flamegraph; main-thread analysis | Default first stop for WebGL hitches; inspect Wasm call stacks | +| **Chrome DevTools > Memory** | Heap snapshot; allocation timeline | Find JS/Wasm memory leaks; compare snapshots before/after scene load | +| **Firefox Profiler** | Cross-platform; shareable URLs; native + Wasm view | Better Wasm symbolication than Chrome in some cases; shareable profile URLs for team review | +| **Safari Web Inspector** | iOS Safari and macOS Safari debugging | Required for Safari-specific issues; WebGL/Wasm runtime differs from Chromium | +| **Unity Profiler over WebSocket** | Connect to a development build; standard markers | Use for Unity-side markers (GC, rendering, scripts); does not capture browser-side overhead | + +**Symptom → tool quick reference:** + +| Symptom | First-line tool | Second-line tool | +|---|---|---| +| WebGL hitch / stutter | Chrome DevTools > Performance | Firefox Profiler | +| Memory climbing over time | Chrome DevTools > Memory | Unity Memory Profiler (WebSocket) | +| Slow initial load | Chrome DevTools > Network | Build Report Inspector | +| Safari-only rendering issue | Safari Web Inspector | Compare with Chrome DevTools | + +**Embedding profiling symbols** — browser profilers show mangled Wasm function names by default. To get readable C# method names in Chrome/Firefox flamegraphs, either enable `Player Settings > Publishing > Debug Symbols` for dev builds, or add a build processor: + +```csharp +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; + +public class WebProfilingBuildProcessor : IPreprocessBuildWithReport +{ + public int callbackOrder => 0; + public void OnPreprocessBuild(BuildReport report) + { + PlayerSettings.SetAdditionalIl2CppArgs("--compiler-flags=--profiling-funcs"); + } +} +``` + +**Emscripten built-in profilers** — enable one at a time via `PlayerSettings.WebGL.emscriptenArgs`: + +| Flag | What it shows | +|---|---| +| `--cpuprofiler` | CPU profiler overlay in browser | +| `--memoryprofiler` | Visual memory map (white=allocated unused, pink=stack, blue=dynamic, green=fragmented) | +| `--threadprofiler` | Thread activity profiler | + +**GPU debugging** — No Frame Debugger support on Web. Use [Spector.js](https://spector.babylonjs.com/) as a browser-based alternative — it captures draw calls and WebGL state. + +**Firefox `about:memory`** — type `about:memory` as a URL in Firefox, click Measure to see per-tab breakdown: WASM code size, WASM heap, .data file, web audio. Watch for WASM heap > 300 MB (crash risk, especially on iOS Safari). + +Editor Play Mode does not represent browser runtime; always measure in browser. Chrome and Safari GC and JIT behavior differ — test both. + +### Web memory directives + +- Disable **Read/Write Enabled** on textures and meshes — it duplicates data into the WASM heap. +- Reduce `.data` file size by moving assets to Addressables or AssetBundles. +- Use compressed texture formats (KTX2/Basis) to reduce both download and decoded memory cost. + +### iOS Safari memory limits + +- **iOS < 18:** WebContent process limit ~1.5 GB. WASM memory (Gigacage) capped at 2 GB. Typed arrays share this pool. On iPhone X (iOS 16) heap growth caps at ~512 MB, but setting Initial Memory Size to 512 MB–1.5 GB upfront works. +- **iOS 18+:** Limits largely lifted; iPhone 11 can allocate ~4 GB. +- On iOS, set **Initial Memory Size** to the target peak rather than relying on growth — Safari handles large upfront allocations better than incremental growth. +- WASM heap > 300 MB risks crashes on older iOS; target < 200 MB for broad compatibility. + +### Video and audio on Web + +- **Video:** Playback only works from a URL (server with CORS enabled) or from StreamingAssets. On iOS the server must support HTTP range requests for streaming. Use browser-compatible formats (MP4/H.264). +- **Audio:** AudioEffects (mixer effects) require compute shaders — **not available on WebGL**. Mixers and MixerGroups work for volume control only. Set audio to **Mono** to improve loading. If `about:memory` shows web audio > 100 MB, audio is likely uncompressed — switch to Vorbis. + +### Canvas and DPI + +If the canvas is scaled up it takes the new resolution. Use `devicePixelRatio` in the web template to offset DPI scaling and avoid rendering at unnecessarily high resolution. + +## 4. Validation + +1. Re-read the Player Settings with the Pre-Flight snippet (compression, stripping, exceptions, targetFrameRate). +2. Rebuild the player and compare Build Report file sizes with baseline. +3. Verify in at least Chrome and Safari (GC and JIT behavior differ). +4. Max **3 iterations** before asking the user for feedback. + +## 5. Troubleshooting + +### Build still large after enabling Strip Engine Code + +1. Is **Managed Stripping Level** set to Medium or Low? → Set to High for release. +2. Are plug-ins using reflection to access engine modules that would otherwise be stripped? → Add a `link.xml` to preserve needed symbols. +3. Is **Exceptions** set to Full? → Full adds the largest code overhead; switch to None or Explicitly Thrown Only. + +### Brotli not working — Decompression Fallback required + +1. Is the server sending `Content-Encoding: br`? → Without this header the browser won't decompress; the fallback JS decompressor is then needed. +2. Is the build hosted over HTTP (not HTTPS)? → Brotli requires a secure context; degrade to Gzip for HTTP hosting. + +### Stutter in Safari but not Chrome + +1. Does the project set `Application.targetFrameRate = 60`? → On Safari WebGL this conflicts with browser pacing; set to `-1`. +2. Are there shaders that behave differently on Safari's WebGL implementation? → Test on device; Safari's WebGL/Wasm runtime differs from Chromium — some GLSL constructs are handled differently. + +### Memory growth slow path triggered + +1. Is **Initial Memory Size** too small for the project's peak? → Wasm memory growth requires a full buffer copy; set Initial Memory Size to a realistic peak estimate. +2. Is **Memory Growth Mode** set to Linear? → Switch to **Geometric** for saner growth curve. + +### Frame rate set to 60 but browser runs erratically + +1. Is `Application.targetFrameRate = 60` set in code? → On Web this conflicts with `requestAnimationFrame` browser pacing. Set to `-1`. +2. Is `vSyncCount` non-zero? → Set to 0; the browser handles pacing. + +### Firefox cache rejecting large files + +Firefox limits individual cache entries via `browser.cache.disk.max_entry_size`. If the build exceeds this (default ~50 MB), assets won't cache. Solution: use Addressables to split into bundles < 51 MB, or instruct users to increase the setting in `about:config`. + +### Local dev server setup + +For testing builds locally with proper MIME types: + +```bash +# Python (HTTP) +python -m http.server 55553 -d path/to/build + +# Node.js (install serve-handler) +npx serve path/to/build -l 3001 +``` + +For Brotli testing, use HTTPS — Brotli requires a secure context. Generate a self-signed cert with OpenSSL for local testing. + +## 6. Completion + +- Summarize: initial download size delta, settings changed (compression, stripping, exceptions, targetFrameRate), server configuration confirmed. +- List follow-up actions: CDN setup for Addressables remote groups, Safari testing, Wasm 2023 feature set upgrade when browser baseline allows. + +## See also + +These point at Unity tooling rather than other skills, because the topics they cover are not in +this plugin: + +- **Addressables package** — remote groups served over a CDN, when the download budget needs content + moved out of the initial payload. +- **Unity Profiler, connected to the browser** — the cross-platform profiling methodology. Section 3 + covers the Web-specific part of attaching it. +- **Shader variant stripping** (Graphics settings → Shader Stripping, and `ShaderVariantCollection`) + — variant count feeds directly into Wasm size, so it is worth checking when stripping alone hasn't + moved the number. +- **Project Settings → Player** — the same flags this skill reads, if the user would rather see them + in the inspector than have them reported. +- Mobile browser battery behaviour follows the same frame-rate and quality-level guidance in + Sections 3 and 4; there is no separate mobile path here. diff --git a/skills/optimize-web/resources/WebOptimizer.cs b/skills/optimize-web/resources/WebOptimizer.cs new file mode 100644 index 0000000..e17dc5f --- /dev/null +++ b/skills/optimize-web/resources/WebOptimizer.cs @@ -0,0 +1,21 @@ +using UnityEditor; +using UnityEditor.Build; + +public class WebOptimizer +{ + [MenuItem("Tools/Apply Web Release Settings")] + public static void Optimize() + { + var target = NamedBuildTarget.WebGL; + PlayerSettings.SetIl2CppCodeGeneration(target, Il2CppCodeGeneration.OptimizeSize); + PlayerSettings.SetManagedStrippingLevel(target, ManagedStrippingLevel.High); + PlayerSettings.stripUnusedMeshComponents = true; + PlayerSettings.WebGL.dataCaching = true; + PlayerSettings.WebGL.compressionFormat = WebGLCompressionFormat.Brotli; + PlayerSettings.WebGL.exceptionSupport = WebGLExceptionSupport.None; + PlayerSettings.WebGL.debugSymbolMode = WebGLDebugSymbolMode.Off; + PlayerSettings.WebGL.wasm2023 = true; + UnityEditor.WebGL.UserBuildSettings.codeOptimization = + UnityEditor.WebGL.WasmCodeOptimization.DiskSizeLTO; + } +} diff --git a/skills/optimize-web/resources/toktx-examples.sh b/skills/optimize-web/resources/toktx-examples.sh new file mode 100644 index 0000000..14e1698 --- /dev/null +++ b/skills/optimize-web/resources/toktx-examples.sh @@ -0,0 +1,11 @@ +# Albedo / diffuse (ETC1S, lossy, smallest) +toktx --bcmp --lower_left_maps_to_s0t0 output.ktx2 input.png + +# Normal / metallic / detail (UASTC, high fidelity) +toktx --encode uastc --uastc_quality 2 --t2 --lower_left_maps_to_s0t0 output.ktx2 input.png + +# Fix "ICC profile not found" errors +toktx --bcmp --assign_oetf srgb --lower_left_maps_to_s0t0 output.ktx2 input.png + +# Linear data (normal maps, masks) +toktx --bcmp --assign_oetf linear --lower_left_maps_to_s0t0 output.ktx2 input.png