This guide explains how to understand, extend, and work with the effect chaining system in ScreenSaverKit.
| File | Purpose |
|---|---|
SSKMetalRenderer.h/m |
Main coordinator; manages passes, frame lifecycle, trail persistence |
SSKMetalPass.h/m |
Abstract base class for all FX passes |
SSKMetalParticlePass.h/m |
Particle rendering: direct, indirect, and ribbon modes |
SSKMetalSpritePass.h/m |
2D sprite rendering: instanced quads, animation, culling |
SSKMetalTrailPass.h/m |
Trail persistence: persistent offscreen texture with fade kernel |
SSKMetalBlurPass.h/m |
Gaussian blur (compute pipeline) |
SSKMetalBloomPass.h/m |
Bloom/glow effect (compute pipeline) |
SSKMetalTextureCache.h/m |
Texture pooling for intermediate renders |
SSKParticleShaders.metal |
Rendering shaders, instance building, ribbon, trail fade kernels |
SSKSpriteShaders.metal |
Sprite vertex/fragment shaders (position, UV, tint) |
SSKSimulationShaders.metal |
GPU simulation: physics, curl noise, attractors, color gradient |
SSKParticleSystem.h/m |
CPU-side particle management and simulation |
SSKSprite.h/m |
Sprite model (position, size, scale, animation, z-order) |
SSKMetalParticleRenderer.h/m |
Convenience wrapper for particle-only workflows |
// In your saver's renderMetalFrame:deltaTime: method
- (void)renderMetalFrame:(SSKMetalRenderer *)renderer deltaTime:(NSTimeInterval)dt {
// Update simulation
[self.particleSystem advanceBy:dt];
// Get snapshot of live particles
NSArray<SSKParticle *> *particles = [self.particleSystem aliveParticlesSnapshot];
// Render particles to screen
[renderer drawParticles:particles
blendMode:SSKParticleBlendModeAlpha
viewportSize:self.bounds.size];
}- (void)renderMetalFrame:(SSKMetalRenderer *)renderer deltaTime:(NSTimeInterval)dt {
[self.particleSystem advanceBy:dt];
NSArray<SSKParticle *> *particles = [self.particleSystem aliveParticlesSnapshot];
// Step 1: Render particles
[renderer drawParticles:particles
blendMode:SSKParticleBlendModeAlpha
viewportSize:self.bounds.size];
// Step 2: Apply blur (motion blur effect)
if (self.motionBlurRadius > 0.01) {
[renderer applyBlur:self.motionBlurRadius];
}
}- (void)renderMetalFrame:(SSKMetalRenderer *)renderer deltaTime:(NSTimeInterval)dt {
[self.particleSystem advanceBy:dt];
NSArray<SSKParticle *> *particles = [self.particleSystem aliveParticlesSnapshot];
// Step 1: Render particles (use additive blending for bloom effect)
[renderer drawParticles:particles
blendMode:SSKParticleBlendModeAdditive
viewportSize:self.bounds.size];
// Step 2: Apply bloom (extract bright areas and blur them)
if (self.bloomIntensity > 0.05) {
renderer.bloomThreshold = 0.7; // Only bloom pixels above 70% brightness
renderer.bloomBlurSigma = 3.0; // Blur spread
[renderer applyBloom:self.bloomIntensity];
}
}- (void)renderMetalFrame:(SSKMetalRenderer *)renderer deltaTime:(NSTimeInterval)dt {
[self.particleSystem advanceBy:dt];
NSArray<SSKParticle *> *particles = [self.particleSystem aliveParticlesSnapshot];
// Clear the drawable with a color
[renderer clearWithColor:MTLClearColorMake(0.0, 0.0, 0.0, 1.0)];
// Step 1: Draw particles
[renderer drawParticles:particles
blendMode:self.particleSystem.blendMode
viewportSize:self.bounds.size];
// Step 2: Optional blur
if (self.blurRadius > 0.01) {
[renderer applyBlur:self.blurRadius];
}
// Step 3: Optional bloom (depends on blur being available)
if (self.bloomIntensity > 0.05) {
renderer.bloomThreshold = self.bloomThreshold;
renderer.bloomBlurSigma = self.bloomBlurSigma;
[renderer applyBloom:self.bloomIntensity];
}
}- (void)renderMetalFrame:(SSKMetalRenderer *)renderer deltaTime:(NSTimeInterval)dt {
[self.particleSystem advanceBy:dt];
NSArray<SSKParticle *> *particles = [self.particleSystem aliveParticlesSnapshot];
// Trail persistence: particles leave slowly fading trails
// (configured once in setup, not per-frame)
// renderer.trailPersistenceEnabled = YES;
// renderer.trailFadeRate = 0.02;
[renderer drawParticles:particles
blendMode:SSKParticleBlendModeAdditive
viewportSize:self.bounds.size];
if (self.bloomIntensity > 0.05) {
[renderer applyBloom:self.bloomIntensity];
}
}When trail persistence is enabled, the renderer automatically:
- Fades the persistent trail texture by
trailFadeRateeach frame (compute kernel) - Redirects particle rendering to the trail texture
- Blits the trail texture to the drawable before post-processing
// Setup (once)
self.particleSystem.noiseScale = 0.002;
self.particleSystem.noiseStrength = 300.0;
self.particleSystem.noiseSpeed = 0.3;
self.particleSystem.globalDamping = 0.3;
[self.particleSystem setAttractorAtIndex:0 position:center strength:5000.0];
self.metalRenderer.trailPersistenceEnabled = YES;
self.metalRenderer.trailFadeRate = 0.02;
// Spawn (continuous emission)
[system spawnParticles:8 initializer:^(SSKParticle *p) {
p.behaviorOptions = SSKParticleBehaviorOptionFadeAlpha
| SSKParticleBehaviorOptionColorGradient
| SSKParticleBehaviorOptionCurlNoise
| SSKParticleBehaviorOptionAttractors;
p.color = brightColor;
// endColor set via GPU spawn parameters (endColorMin/endColorMax)
}];For systems with many particles, indirect rendering avoids CPU readback of alive particle data:
- (void)renderMetalFrame:(SSKMetalRenderer *)renderer deltaTime:(NSTimeInterval)dt {
[self.particleSystem advanceBy:dt];
// GPU builds instance buffer directly — no CPU snapshot needed
[renderer drawParticlesIndirect:self.particleSystem
blendMode:self.particleSystem.blendMode
viewportSize:self.bounds.size];
}The indirect pipeline runs three compute passes in a single command buffer:
compactAliveIndices— scan particle states, build alive index listprepareIndirectArgs— writeMTLDrawPrimitivesIndirectArgumentsbuildInstanceData— populate instance buffer from alive particles
What it does: Renders particle quads to the screen using instanced rendering.
Key concepts:
- Uses a quad vertex buffer (4 vertices, pre-allocated once)
- Uses an instance buffer that grows dynamically as particle count increases
- Supports two blend modes: Alpha compositing or Additive blending
- Each particle becomes a "soft disc" due to fragment shader softness parameter
Data per particle:
typedef struct {
vector_float2 position; // World position
vector_float2 direction; // Trail direction (for orientation)
float width; // Trail width
float length; // Trail length (calculated from z-depth or length multiplier)
vector_float4 color; // RGBA
float softness; // Edge feathering (from particle.userScalar, 0 for z-depth)
float rotation; // Per-particle rotation in radians (applied in vertex shader)
float padding[2]; // Alignment
} SSKMetalInstanceData;Z-Depth Rendering Support:
- When z-depth is enabled,
userScalarcontains z-depth value (0.01-1.0) - Length calculated as:
width * lengthMultiplier * zDepth(far drops are shorter) - Length multiplier stored in
SSKMetalParticlePass.lengthMultiplierproperty - Softness set to 0 for z-depth particles (hard edges for retro rain effect)
- Color already darkened by z-depth during GPU spawn
When particles render soft-edged:
// Fragment shader applies Gaussian falloff
float alpha = in.color.a * exp(-softness * dist * dist * 4.0);Blend mode selection:
// Alpha mode: standard alpha compositing
src: (one, one_minus_src_alpha)
// Additive mode: for glow/energy effects
src: (one, one)What it does: Applies separable Gaussian blur to the render target.
Two-pass separable design:
- Horizontal pass: Convolve along X axis (using shared texture cache)
- Vertical pass: Convolve along Y axis
Why separable:
- O(n) instead of O(n²) for blur radius n
- Much faster on GPU (fewer memory accesses)
Key parameters:
self.radius = sigma; // Gaussian standard deviationKernel size computed as:
float radius = max(1.0f, sigma * 3.0f); // 3-sigma ruleUsage:
if (blurRadius > 0.01) {
[renderer applyBlur:blurRadius];
}What it does: Extracts bright pixels, blurs them, and composites back (glow effect).
Three-stage pipeline:
-
Threshold kernel (compute):
- Input: Render target
- Output: brightTexture (only pixels above threshold)
float lum = bloomLuminance(srcColor.rgb); float bloomFactor = max(lum - threshold, 0.0f);
-
Blur kernel (delegates to SSKMetalBlurPass):
- Input: brightTexture
- Output: blurredTexture
- Uses separable Gaussian
-
Composite kernel (compute):
- Input: blurredTexture, render target
- Output: Blended back to render target
float glow = bloom.a * intensity; dest.rgb = clamp(dest.rgb + bloom.rgb * glow, 0.0, 1.0);
Key parameters:
renderer.bloomThreshold = 0.8; // Extract pixels above 80% luminance
renderer.bloomBlurSigma = 3.0; // Blur spread
[renderer applyBloom:1.0]; // Glow intensity (1.0 = normal)Luminance formula:
float bloomLuminance(float3 color) {
return dot(color, float3(0.2126f, 0.7152f, 0.0722f)); // ITU-R BT.709
}What it does: Maintains a persistent offscreen texture that accumulates particle renders over time, producing long luminous trails.
Key concepts:
- Owns its own texture (NOT in the texture cache — must survive trimming)
- Texture allocated lazily on first use, recreated if drawable size changes
- Each frame: fade previous contents via compute kernel, then blit new content on top
Fade kernel:
// Multiplies all pixels by (1.0 - fadeRate) each frame
kernel void trailFadeKernel(texture2d<float, access::read_write> tex,
constant float &fadeRate,
uint2 gid) {
float4 color = tex.read(gid);
color *= (1.0 - fadeRate);
tex.write(color, gid);
}Integration flow:
- Renderer calls
fadeWithRate:commandBuffer:— compute kernel fades trail texture - Particles render to trail texture (instead of directly to drawable)
- Renderer calls
blitTo:destination:commandBuffer:— copies trail to drawable - Post-processing (blur, bloom) applies to drawable as usual
Configuration:
renderer.trailPersistenceEnabled = YES; // Enable the trail pass
renderer.trailFadeRate = 0.02; // Slow fade (0.0 = none, 1.0 = instant)Texture Cache: The Hidden Hero
Without caching, each frame allocates new textures for intermediate renders. This is expensive:
- Metal texture allocation has CPU overhead
- Fragmentation can occur
- Memory pressure increases
The SSKMetalTextureCache solves this by pooling textures.
// Inside blur pass:
MTLTextureUsage usage = MTLTextureUsageShaderRead | MTLTextureUsageShaderWrite;
// Acquire or create a scratch texture matching the source
id<MTLTexture> scratch = [textureCache acquireTextureMatchingTexture:source
usage:usage];
// Use it for horizontal pass
[encoder setTexture:source atIndex:0];
[encoder setTexture:scratch atIndex:1];
[encoder dispatchThreadgroups:...];
// Use it for vertical pass
[encoder setTexture:scratch atIndex:0];
[encoder setTexture:destination atIndex:1];
[encoder dispatchThreadgroups:...];
// Return to pool for reuse next frame
[textureCache releaseTexture:scratch];Textures are organized in buckets by key:
// Key = (width, height, pixelFormat, usage)
uint64_t key = ((uint64_t)width << 32) ^ ((uint64_t)height << 16)
^ ((uint64_t)format << 8) ^ (uint64_t)usage;Multiple textures of the same size/format are pooled together. When you need one, the cache returns the first available from the bucket. If none exist, a new one is created.
Let's add a simple effect that shifts colors (hue rotation).
File: SSKMetalColorShiftPass.h
#import "SSKMetalPass.h"
@class SSKMetalTextureCache;
NS_ASSUME_NONNULL_BEGIN
@interface SSKMetalColorShiftPass : SSKMetalPass
@property (nonatomic) CGFloat hueShift; // 0-360 degrees
- (BOOL)setupWithDevice:(id<MTLDevice>)device library:(id<MTLLibrary>)library;
- (BOOL)encodeColorShift:(id<MTLCommandBuffer>)commandBuffer
source:(id<MTLTexture>)source
renderTarget:(id<MTLTexture>)renderTarget
textureCache:(SSKMetalTextureCache *)textureCache;
@end
NS_ASSUME_NONNULL_ENDFile: SSKMetalColorShiftPass.m
#import "SSKMetalColorShiftPass.h"
#import "SSKMetalTextureCache.h"
#import "SSKDiagnostics.h"
@interface SSKMetalColorShiftPass ()
@property (nonatomic, strong) id<MTLDevice> device;
@property (nonatomic, strong) id<MTLComputePipelineState> colorShiftPipeline;
@end
@implementation SSKMetalColorShiftPass
- (BOOL)setupWithDevice:(id<MTLDevice>)device library:(id<MTLLibrary>)library {
NSParameterAssert(device);
NSParameterAssert(library);
if (!device || !library) return NO;
self.device = device;
NSError *error = nil;
id<MTLFunction> shiftFunc = [library newFunctionWithName:@"colorShiftKernel"];
if (!shiftFunc) {
if ([SSKDiagnostics isEnabled]) {
[SSKDiagnostics log:@"SSKMetalColorShiftPass: missing colorShiftKernel in library"];
}
return NO;
}
self.colorShiftPipeline = [device newComputePipelineStateWithFunction:shiftFunc error:&error];
if (!self.colorShiftPipeline) {
if ([SSKDiagnostics isEnabled]) {
[SSKDiagnostics log:@"SSKMetalColorShiftPass: failed to create pipeline: %@",
error.localizedDescription];
}
return NO;
}
return YES;
}
- (BOOL)encodeColorShift:(id<MTLCommandBuffer>)commandBuffer
source:(id<MTLTexture>)source
renderTarget:(id<MTLTexture>)renderTarget
textureCache:(SSKMetalTextureCache *)textureCache {
if (!commandBuffer || !source || !renderTarget || !self.colorShiftPipeline) {
return NO;
}
id<MTLComputeCommandEncoder> encoder = [commandBuffer computeCommandEncoder];
if (!encoder) return NO;
float hue = fmod((float)self.hueShift, 360.0f);
MTLSize threadGroups = MTLSizeMake(
(source.width + 15) / 16,
(source.height + 15) / 16,
1
);
MTLSize threadsPerGroup = MTLSizeMake(16, 16, 1);
[encoder setComputePipelineState:self.colorShiftPipeline];
[encoder setTexture:source atIndex:0];
[encoder setTexture:renderTarget atIndex:1];
[encoder setBytes:&hue length:sizeof(float) atIndex:0];
[encoder dispatchThreadgroups:threadGroups threadsPerThreadgroup:threadsPerGroup];
[encoder endEncoding];
return YES;
}
@endAdd to SSKParticleShaders.metal:
kernel void colorShiftKernel(texture2d<float, access::sample> source [[texture(0)]],
texture2d<float, access::write> destination [[texture(1)]],
constant float &hueShift [[buffer(0)]],
uint2 gid [[thread_position_in_grid]]) {
if (gid.x >= destination.get_width() || gid.y >= destination.get_height()) {
return;
}
constexpr sampler s(address::clamp_to_edge, filter::nearest);
float4 color = source.sample(s, float2(gid) / float2(source.get_width(), source.get_height()));
// RGB to HSV
float3 c = color.rgb;
float maxC = max(max(c.r, c.g), c.b);
float minC = min(min(c.r, c.g), c.b);
float delta = maxC - minC;
float h = 0.0f;
if (delta > 0.0001f) {
if (maxC == c.r) h = fmod((c.g - c.b) / delta, 6.0f);
else if (maxC == c.g) h = (c.b - c.r) / delta + 2.0f;
else h = (c.r - c.g) / delta + 4.0f;
h = h / 6.0f;
}
float s = (maxC > 0.0001f) ? (delta / maxC) : 0.0f;
float v = maxC;
// Apply hue shift
h = fmod(h + (hueShift / 360.0f), 1.0f);
// HSV back to RGB
float c_val = v * s;
float h_prime = h * 6.0f;
float x = c_val * (1.0f - fabs(fmod(h_prime, 2.0f) - 1.0f));
float3 rgb = float3(0.0f);
if (h_prime < 1.0f) rgb = float3(c_val, x, 0.0f);
else if (h_prime < 2.0f) rgb = float3(x, c_val, 0.0f);
else if (h_prime < 3.0f) rgb = float3(0.0f, c_val, x);
else if (h_prime < 4.0f) rgb = float3(0.0f, x, c_val);
else if (h_prime < 5.0f) rgb = float3(x, 0.0f, c_val);
else rgb = float3(c_val, 0.0f, x);
float3 result = rgb + (v - c_val);
destination.write(float4(result, color.a), gid);
}With the effect stage system you no longer add hard-coded methods to the renderer. Instead, create a stage and register it with a unique identifier:
SSKMetalColorShiftPass *colorShiftPass = [SSKMetalColorShiftPass new];
if ([colorShiftPass setupWithDevice:device library:_shaderLibrary]) {
SSKMetalEffectStage *stage =
[[SSKMetalEffectStage alloc] initWithIdentifier:@"demo.colorShift"
pass:colorShiftPass
handler:^BOOL(SSKMetalRenderer *renderer,
SSKMetalPass *pass,
id<MTLCommandBuffer> commandBuffer,
id<MTLTexture> renderTarget,
NSDictionary *parameters) {
SSKMetalColorShiftPass *shiftPass = (SSKMetalColorShiftPass *)pass;
CGFloat hueDegrees = MAX(0.0, [parameters[@"hueShift"] doubleValue]);
shiftPass.hueShift = fmod(hueDegrees, 360.0);
return [shiftPass encodeColorShift:commandBuffer
source:renderTarget
renderTarget:renderTarget
textureCache:renderer.textureCache];
}];
[self registerEffectStage:stage];
} else if ([SSKDiagnostics isEnabled]) {
[SSKDiagnostics log:@"SSKMetalRenderer: color shift pass unavailable"];
}Once registered the stage can be reconfigured or removed at runtime using registerEffectStage: and unregisterEffectStageWithIdentifier:.
- (void)renderMetalFrame:(SSKMetalRenderer *)renderer deltaTime:(NSTimeInterval)dt {
[self.particleSystem advanceBy:dt];
NSArray<SSKParticle *> *particles = [self.particleSystem aliveParticlesSnapshot];
[renderer drawParticles:particles
blendMode:self.particleSystem.blendMode
viewportSize:self.bounds.size];
if (self.bloomIntensity > 0.05) {
[renderer applyBloom:self.bloomIntensity];
}
if (self.hueShiftEnabled) {
NSDictionary *params = @{ @"hueShift": @(self.currentHueShift) };
[renderer applyEffectWithIdentifier:@"demo.colorShift" parameters:params];
}
}To chain multiple custom effects, build an ordered array of identifiers and pass it to applyEffects:parameters: for a consistent pipeline.
if (self.effectStrength > 0.01) {
[renderer applyEffect:self.effectStrength];
}[renderer drawParticles:...];
[renderer applyEffect1:...]; // Modifies drawable
[renderer applyEffect2:...]; // Modifies drawable (output of Effect1)
[renderer applyEffect3:...]; // Modifies drawable (output of Effect2)// Different chain based on mode
if (self.useBloomFirst) {
[renderer applyBloom:intensity];
[renderer applyBlur:radius];
} else {
[renderer applyBlur:radius];
[renderer applyBloom:intensity];
}// Effect is optional; system handles if pass unavailable
if (bloomIntensity > 0.05) {
[renderer applyBloom:bloomIntensity];
// If bloom unavailable, applyBloom returns silently (checked internally)
}Look at initialization logs:
[SSKDiagnostics setEnabled:YES];
// ... create renderer ...
// Check console for "SSKMetalRenderer: failed to set up <pass>"// Check Xcode build log for Metal compilation errors
// Look in Build Phases → Compile Metal SourcesTo see intermediate results:
// Capture the render target after each pass
id<MTLTexture> intermediateResult = [self captureCurrentRenderTarget];
// Save to disk or examine in debugger// Add logging to SSKMetalTextureCache
NSLog(@"Cache has %lu buckets", (unsigned long)self.textureCache.textureBuckets.count);- Blur: Creates 1 scratch texture per frame
- Bloom: Creates 2 temporary textures per frame
- Solution: Already uses texture cache
- Each applyEffect* call dispatches a compute kernel
- Kernel setup has overhead
- Solution: Combine passes where possible (e.g., bloom already includes blur)
- Reading/writing large textures is bottleneck on GPU
- Multiple passes increase bandwidth usage
- Solution: Use compute shaders (more cache-friendly than render passes)
- Recent optimization (commit be49dc9)
- Particle simulation no longer blocks CPU
- Uses completion handlers instead of waitUntilCompleted
- Result: Better GPU/CPU parallelism
| Issue | Solution |
|---|---|
| Bloom not working | Ensure bloomIntensity > 0.05 and the blur stage is available (or allow bloom's fallback blur to compile) |
| Blur has no effect | Check radius > 0.01 |
| Effects render to wrong texture | Verify setRenderTarget: not used incorrectly |
| Memory leaks in texture cache | Ensure releaseTexture: called for all acquired textures |
| Shader function not found | Check function name in kernel matches library |
| Particles not rendered | Check drawParticles: called with non-empty array |
| Performance degradation | Profile texture allocation; check cache hit rate |
| Z-depth not visible | Ensure zDepthEnabled = 1u in spawn parameters, check lengthMultiplier is set on particle pass |
| GPU spawn returns 0 | Check Metal device available, verify shader library loaded, check particle capacity |
| Z-depth particles too slow | Adjust zDepthScale to reduce minimum z-depth (higher scale = more distant drops) |
| Trails not visible | Set trailPersistenceEnabled = YES and trailFadeRate to a small value (e.g. 0.02) |
| Trails flash or strobe | trailFadeRate too high — try 0.01–0.05 for smooth results |
| Curl noise has no effect | Set noiseStrength > 0 on the system AND SSKParticleBehaviorOptionCurlNoise on particles |
| Attractors ignored | Call setAttractorAtIndex:position:strength: AND set SSKParticleBehaviorOptionAttractors on particles |
| Color gradient not interpolating | Set SSKParticleBehaviorOptionColorGradient and provide endColorMin/endColorMax in spawn params |
| Ribbon mode renders quads | Enable ribbonModeEnabled on the system AND SSKParticleBehaviorOptionRibbonMode on particles |
| Indirect rendering crashes | Ensure SSKSimulationShaders.metallib is included in the build and the particle system has a valid Metal device |
The particle system supports hardware-accelerated batch particle initialization via Metal compute shaders:
SSKParticleSpawnParameters params = SSKParticleSpawnParametersMake();
params.regionType = SSKParticleSpawnRegionTypeRectangle;
params.center = (vector_float2){width/2, height + 50};
params.size = (vector_float2){(float)width, 20.0f};
params.velocityXRange = (vector_float2){vx * 0.9f, vx * 1.1f};
params.velocityYRange = (vector_float2){vy * 0.9f, vy * 1.1f};
params.sizeRange = (vector_float2){2.0f, 4.0f};
params.lifeRange = (vector_float2){2.0f, 3.0f};
params.colorMin = (vector_float4){0.2f, 0.2f, 0.2f, 1.0f};
params.colorMax = (vector_float4){0.8f, 0.8f, 0.8f, 1.0f};
params.behaviorOptions = SSKParticleBehaviorOptionFadeAlpha;
// Enable z-depth for perspective effect
params.zDepthEnabled = 1u;
params.zDepthScale = 10.0f; // Moderate depth variation
params.lengthMultiplier = 8.0f; // Base length for rendering
NSUInteger spawned = [self.particleSystem spawnParticlesGPU:count parameters:params];When zDepthEnabled is set:
- Z-Depth Calculation: Random z-depth value (minZ to 1.0) calculated per particle
minZ = max(0.2, 1.0 / (zDepthScale + 1.0))- Scale 1.0 → minZ ~0.5 (subtle effect)
- Scale 100.0 → minZ ~0.2 (strong effect)
- Velocity Scaling:
velocity *= zDepth(far drops move slower) - Length Scaling: Applied during rendering as
length *= zDepth - Color Darkening:
brightness *= (0.3 + zDepth * 0.7)(far drops darker) - Storage: Z-depth stored in
particle.userScalar(0.01-1.0 range)
- Parallel Initialization: All particles initialized simultaneously on GPU
- No CPU Overhead: Z-depth calculations performed entirely on GPU
- Async Option: Set
synchronizesMetalSpawn = NOfor non-blocking spawn - Fallback: Automatically falls back to CPU spawn if GPU unavailable
See ../Demos/Rain/RainView.m for a complete example of z-depth usage:
- GPU spawn with z-depth enabled
- Length multiplier synchronization with renderer
- CPU fallback path with matching z-depth calculations
When adding new effects or modifying existing ones, ensure you have test coverage:
- Create Test File:
SSKYourEffectPassTests.m - Test Initialization: Verify setup with device/library
- Test Encoding: Verify encode method with valid/invalid inputs
- Test Metal Availability: Skip tests gracefully when Metal unavailable
Example:
- (void)testYourEffectPassSetup {
id<MTLDevice> device = MTLCreateSystemDefaultDevice();
if (!device) {
NSLog(@"Skipping test - Metal unavailable");
return;
}
id<MTLLibrary> library = [TestHelpers loadParticleShaderLibraryWithDevice:device];
SSKYourEffectPass *pass = [[SSKYourEffectPass alloc] init];
XCTAssertTrue([pass setupWithDevice:device library:library]);
}Test your effect in the full rendering pipeline:
- (void)testYourEffectInPipeline {
SSKMetalRenderer *renderer = [[SSKMetalRenderer alloc] initWithDevice:device];
// ... setup particles ...
[renderer drawParticles:particles ...];
[renderer applyYourEffect:...];
[renderer endFrame];
// Verify result
}Always check Metal availability:
if (![TestHelpers loadParticleShaderLibraryWithDevice:device]) {
NSLog(@"Skipping Metal test");
return;
}See ARCHITECTURE_ANALYSIS.md → "Testing Architecture" for more details.
ARCHITECTURE_ANALYSIS.md– Deep dive into design, includes testing architectureARCHITECTURE_DIAGRAMS.md– Visual component relationshipsSSKParticleSystem.md– Detailed particle system docs (cheat sheet)tutorial.md– End-to-end saver creation guideDemos/Flux/FluxView.m– Curl noise + attractors + trails + bloom exampleDemos/RibbonFlow/RibbonFlowView.m– Ribbon mode exampleDemos/Rain/README.md– Z-depth implementation example../Tests/README.md– Test suite documentation (145 tests)PERFORMANCE_TESTING.md– Performance benchmarking guide