Skip to content

Commit 7428257

Browse files
committed
feat: depth-of-field — soft-focus + dim the background (#11)
Pushes the background back so the speaker pops: a soft-focus blur + a dim behind the sharp character. Viewer-local, OFF by default (Settings → "Depth of field"). - go-sdl2 exposes no per-texture scale mode, so a real linear-filtered downscale blur would need fragile global-hint juggling on the render thread. Instead the bg is soft-focused by SMEARING: the sharp bg plus eight low-alpha offset ghosts (reusing the outlineDirs ring, cardinals brighter than diagonals), then a dim overlay. The shared bg page's alpha is set per ghost and RESTORED to opaque. Replaces only the background draw — sprites/desk stay sharp. 0-alloc (cached page + scratch rect). - SpriteFX.DoF flows via SetSpriteFX; config DepthOfField pref + Settings toggle. Test: a 0-alloc render gate with DoF on. render+config+ui -race green; BenchmarkRenderFrame still 0 allocs/op. Feature 7 of 10. (A true Gaussian blur is a follow-up if the smear reads too subtle.)
1 parent e42e5a1 commit 7428257

5 files changed

Lines changed: 109 additions & 1 deletion

File tree

‎internal/config/preferences.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,7 @@ type AssetPreferences struct {
644644
PostScanlines bool `json:"postScanlines"`
645645
PostGrain bool `json:"postGrain"`
646646
AnimateEntrances bool `json:"animateEntrances"`
647+
DepthOfField bool `json:"depthOfField"`
647648
FriendNotify bool `json:"friendNotify"`
648649
FriendOSToast bool `json:"friendOSToast"`
649650
CallwordOSToast bool `json:"callwordOSToast"` // #M4 desktop toast on callword
@@ -858,6 +859,7 @@ type prefsJSON struct {
858859
PostScanlines bool `json:"postScanlines"` // default OFF
859860
PostGrain bool `json:"postGrain"` // default OFF
860861
AnimateEntrances bool `json:"animateEntrances"` // default OFF
862+
DepthOfField bool `json:"depthOfField"` // default OFF
861863
SpriteTintColor *int `json:"spriteTintColor"` // absent = default
862864
FriendNotify bool `json:"friendNotify"` // default OFF
863865
FriendOSToast bool `json:"friendOSToast"` // default OFF
@@ -1367,6 +1369,7 @@ func load(path string) (*AssetPreferences, error) {
13671369
p.PostScanlines = onDisk.PostScanlines
13681370
p.PostGrain = onDisk.PostGrain
13691371
p.AnimateEntrances = onDisk.AnimateEntrances
1372+
p.DepthOfField = onDisk.DepthOfField
13701373
if onDisk.SpriteTintColor != nil {
13711374
p.SpriteTintColor = *onDisk.SpriteTintColor & 0xFFFFFF
13721375
}
@@ -3275,6 +3278,17 @@ func (p *AssetPreferences) AnimateEntrancesOn() bool {
32753278
// SetAnimateEntrances toggles the entrance slide-in.
32763279
func (p *AssetPreferences) SetAnimateEntrances(on bool) { p.setBoolPref(&p.AnimateEntrances, on) }
32773280

3281+
// DepthOfFieldOn reports the #11 toggle (OFF by default): soft-focus + dim the background
3282+
// behind the sharp speaker.
3283+
func (p *AssetPreferences) DepthOfFieldOn() bool {
3284+
p.mu.RLock()
3285+
defer p.mu.RUnlock()
3286+
return p.DepthOfField
3287+
}
3288+
3289+
// SetDepthOfField toggles the background depth-of-field.
3290+
func (p *AssetPreferences) SetDepthOfField(on bool) { p.setBoolPref(&p.DepthOfField, on) }
3291+
32783292
// setBoolPref sets *field to on under the lock, marking dirty only on a real change.
32793293
func (p *AssetPreferences) setBoolPref(field *bool, on bool) {
32803294
p.mu.Lock()

‎internal/render/dof_test.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package render
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/veandco/go-sdl2/sdl"
8+
)
9+
10+
// TestDepthOfFieldZeroAlloc pins #11: the background soft-focus + dim renders with zero
11+
// per-frame heap allocations (cached bg page + a scratch rect). Off → drawFill as before.
12+
func TestDepthOfFieldZeroAlloc(t *testing.T) {
13+
ren, cleanup := newHeadlessRenderer(t)
14+
defer cleanup()
15+
store, err := NewTextureStore(ren)
16+
if err != nil {
17+
t.Fatal(err)
18+
}
19+
defer store.Purge()
20+
for _, base := range []string{"bg", "desk", "spk", "pair"} {
21+
if err := store.Upload(base, decodedFixture()); err != nil {
22+
t.Fatal(err)
23+
}
24+
}
25+
vp := NewViewport(store)
26+
scene := benchScene(store)
27+
vp.SetSpriteFX(SpriteFX{DoF: true})
28+
rect := sdl.Rect{X: 0, Y: 0, W: 512, H: 384}
29+
30+
allocs := testing.AllocsPerRun(200, func() {
31+
vp.Update(scene, 16*time.Millisecond)
32+
vp.Render(ren, scene, rect)
33+
})
34+
if allocs != 0 {
35+
t.Errorf("depth-of-field render allocates %.1f objects/op, want 0 (#11 zero-perf constraint)", allocs)
36+
}
37+
}

‎internal/render/viewport.go‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ type SpriteFX struct {
128128
Spin bool // slow continuous rotation
129129
ShoutPunch bool // #12: quick scale-pop of the whole stage when a shout appears
130130
Entrance bool // #9: slide the speaker in when a new character takes the stage
131+
DoF bool // #11: soft-focus + dim the background behind the sharp speaker
131132

132133
Speed int // rainbow hue speed slider [1,100] (higher = faster)
133134
Vividness int // rainbow saturation slider [0,100] (higher = more neon)
@@ -346,7 +347,11 @@ func (v *Viewport) Render(ren *sdl.Renderer, scene *courtroom.Scene, vp sdl.Rect
346347
vp.Y += int32(amp / 2 * math.Cos(phase*1.3))
347348
}
348349

349-
v.drawFill(ren, scene.BackgroundBase, &v.bgAnim, vp)
350+
if v.fx.DoF {
351+
v.drawBackgroundDoF(ren, scene.BackgroundBase, &v.bgAnim, vp) // #11 soft-focus + dim
352+
} else {
353+
v.drawFill(ren, scene.BackgroundBase, &v.bgAnim, vp)
354+
}
350355

351356
// Pair hue offset for the "desync" effect: half a period so the two
352357
// characters show opposite hues. Zero unless rainbow + desync are both on,
@@ -426,6 +431,52 @@ func (v *Viewport) drawFill(ren *sdl.Renderer, base string, anim *animState, vp
426431
_ = ren.Copy(page.Frames[frame], nil, &v.fillRect)
427432
}
428433

434+
// Depth-of-field tuning (#11). A real Gaussian needs per-texture linear filtering that
435+
// go-sdl2 doesn't expose, so the background is soft-focused by smearing it: the sharp bg
436+
// plus eight low-alpha offset ghosts (the outlineDirs ring), then a dim overlay to push it
437+
// behind the sharp speaker. All blits + a scratch rect → 0 alloc.
438+
const (
439+
dofBlurDivisor = 90 // ghost offset = vp.H / divisor (floored)
440+
dofBlurMin = 3 //
441+
dofAlphaCard = 120 // cardinal ghost strength
442+
dofAlphaDiag = 80 // diagonal ghost strength
443+
dofDimAlpha = 95 // darkening over the blurred bg
444+
)
445+
446+
// drawBackgroundDoF draws the background soft-focused + dimmed (#11). The bg store page's
447+
// alpha is set per ghost and RESTORED to opaque after (the page is shared T1). 0-alloc.
448+
func (v *Viewport) drawBackgroundDoF(ren *sdl.Renderer, base string, anim *animState, vp sdl.Rect) {
449+
if base == "" {
450+
return
451+
}
452+
page, ok := anim.resolve(v.store)
453+
if !ok || len(page.Frames) == 0 {
454+
return
455+
}
456+
tex := page.Frames[clampFrame(anim.frame, len(page.Frames))]
457+
r := vp.H / dofBlurDivisor
458+
if r < dofBlurMin {
459+
r = dofBlurMin
460+
}
461+
v.fillRect = vp // the sharp base
462+
_ = ren.Copy(tex, nil, &v.fillRect)
463+
for i := range outlineDirs { // eight offset ghosts → a smear blur
464+
a := uint8(dofAlphaCard)
465+
if i >= 4 { // outlineDirs[4:] are the diagonals
466+
a = dofAlphaDiag
467+
}
468+
_ = tex.SetAlphaMod(a)
469+
v.fillRect = vp
470+
v.fillRect.X += outlineDirs[i][0] * r
471+
v.fillRect.Y += outlineDirs[i][1] * r
472+
_ = ren.Copy(tex, nil, &v.fillRect)
473+
}
474+
_ = tex.SetAlphaMod(255)
475+
v.fillRect = vp // dim overlay, pushing the bg behind the sharp speaker
476+
_ = ren.SetDrawColor(0, 0, 0, dofDimAlpha)
477+
_ = ren.FillRect(&v.fillRect)
478+
}
479+
429480
// drawSprite draws a character layer: scaled to viewport height preserving
430481
// aspect, bottom-centered, shifted by percent offsets, optionally mirrored.
431482
// hueShift offsets this layer's rainbow phase (used to desync the pair).

‎internal/ui/app.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3752,6 +3752,7 @@ func (a *App) spriteFX() render.SpriteFX {
37523752
Spin: a.d.Prefs.SpriteSpinOn(),
37533753
ShoutPunch: a.d.Prefs.ShoutPunchOn(),
37543754
Entrance: a.d.Prefs.AnimateEntrancesOn(),
3755+
DoF: a.d.Prefs.DepthOfFieldOn(),
37553756
Speed: a.d.Prefs.RainbowSpeed(),
37563757
Vividness: a.d.Prefs.RainbowVividness(),
37573758
}

‎internal/ui/settings.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,11 @@ func (a *App) drawSettingsGeneral(y, w int32) int32 {
643643
a.d.Prefs.SetAnimateEntrances(next)
644644
}
645645
y += 26
646+
dof := a.d.Prefs.DepthOfFieldOn()
647+
if next := c.Checkbox(pad, y, "Depth of field (OFF by default): soft-focus + dim the background behind the speaker", dof); next != dof {
648+
a.d.Prefs.SetDepthOfField(next)
649+
}
650+
y += 26
646651
// Sprite hover-previews: rest the cursor on a character/emote button to pop a
647652
// full-size preview. ON by default; the dwell before it shows is tunable.
648653
prev := a.d.Prefs.SpritePreviewsOn()

0 commit comments

Comments
 (0)