Skip to content

Commit fced8aa

Browse files
authored
feat: add normal map support for 2D lit sprites (#4)
* feat: add normal map support for 2D lit sprites Add per-pixel normal mapping to the 2D lighting pipeline using a two-shader approach: one shader for standard lit sprites and one with the normal map always sampled. The renderer switches between them via BeginShaderMode, the correct raylib pattern for shader state changes. Key changes: - SpriteState gains a NormalMap field (Texture2D voption) - LightContext2D manages two shader programs with independent location caches and uploads uniforms to both - LitShader exports a normal-map fragment variant using Half-Lambert lighting (NdotL = max(1.0 + dot(normal.xy, L), 0)) for correct 2D behavior - Renderer2D selects the shader variant per-sprite based on NormalMap - New LightDraw.litAnimatedSprite helper for animated sprites - SpriteState moved to top-level type for ergonomic use * fix: address PR #4 review feedback - Remove dead normal map code from standard shader (unused uniforms, getNormal) - Mark LitSprite signature change as breaking in CHANGELOG - Add FlipY handling to litAnimatedSprite
1 parent 78d93b3 commit fced8aa

16 files changed

Lines changed: 714 additions & 197 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
# Changelog
22

3-
[Unreleased]
3+
## [Unreleased]
44

5-
## [1.0.0] - 2026-05-30
5+
### Added
6+
7+
- 2D normal map support: `SpriteState.NormalMap` field for per-pixel lighting on lit sprites. `LightContext2D` manages two shader variants (standard and normal-mapped) and switches between them via `BeginShaderMode`. The normal-map shader uses a 2D-compatible Half-Lambert lighting model (`NdotL = max(1.0 + dot(normal.xy, L), 0)`) for correct visual results with 2D light directions.
8+
- `LightDraw.litAnimatedSprite` helper for animated sprites with automatic flip handling.
9+
- `SpriteState` promoted to top-level type with builder DSL (`create`, `withNormalMap`, `withLayer`, etc.).
10+
11+
### Changed
12+
13+
- **Breaking:** `LitSprite` command signature changed — now carries `LightContext2D * SpriteState` instead of 8 individual fields. Consumers must update pattern matches and `LightDraw.litSprite` call sites to use the new `SpriteState` type.
14+
- `SpriteState` moved from `Command2D` module to top-level `Mibo.Elmish.Graphics2D` namespace.
15+
16+
## [1.0.0] - 2026.05.30
617

718
### Added
819

samples/PlatformerSample/MinimapView.fs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,14 @@ let private viewInner
147147

148148
if minimap.TexReady then
149149
buffer
150-
|> Draw.sprite {
151-
Texture = minimap.Texture
152-
Dest = Rectangle(minimapX, minimapY, minimapSize, minimapSize)
153-
Source = Rectangle(0.0f, 0.0f, float32 texSize, float32 texSize)
154-
Origin = Vector2.Zero
155-
Rotation = 0.0f
156-
Color = Color.White
157-
Layer = 1010<RenderLayer>
158-
}
150+
|> Draw.sprite(
151+
SpriteState.create(
152+
minimap.Texture,
153+
Rectangle(minimapX, minimapY, minimapSize, minimapSize),
154+
Rectangle(0.0f, 0.0f, float32 texSize, float32 texSize)
155+
)
156+
|> SpriteState.withLayer 1010<RenderLayer>
157+
)
159158
|> Draw.drop
160159

161160
let centerX = minimapX + halfMinimap

samples/PlatformerSample/Program.fs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ open Mibo.Elmish.Graphics2D
88
open Mibo.Elmish.Graphics2D.Lighting
99
open Mibo.Input
1010
open Mibo.Animation
11+
open Mibo.Layout
1112
open PlatformerSample.Constants
1213
open PlatformerSample.Types
1314
open PlatformerSample.WorldGen
@@ -34,6 +35,7 @@ let loadAssets(ctx: GameContext) : SpriteAssets =
3435

3536
let font = assets.Font("assets/Fonts/monogram.ttf")
3637
let jumpSound = assets.Sound("assets/sfx_jump.ogg")
38+
let coinNormalMap = assets.Texture("assets/NormalMap.png")
3739

3840
// White 1x1 texture for particles (black would multiply to zero)
3941
let particleImg =
@@ -43,7 +45,7 @@ let loadAssets(ctx: GameContext) : SpriteAssets =
4345
Raylib.UnloadImage(particleImg)
4446

4547
let playerSheet =
46-
SpriteSheet.fromFrames playerTex (Vector2(64.0f, 64.0f)) [|
48+
SpriteSheet.fromFrames playerTex Vector2.Zero [|
4749
struct ("idle",
4850
{
4951
Frames = [| r 645 0 128 128 |]
@@ -86,6 +88,7 @@ let loadAssets(ctx: GameContext) : SpriteAssets =
8688
TileTexture = tileTex
8789
TorchSheet = torchSheet
8890
ParticleTexture = particleTex
91+
CoinNormalMap = coinNormalMap
8992
Font = font
9093
JumpSound = jumpSound
9194
}

samples/PlatformerSample/Types.fs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ type SpriteAssets = {
7171
TileTexture: Texture2D
7272
TorchSheet: SpriteSheet
7373
ParticleTexture: Texture2D
74+
CoinNormalMap: Texture2D
7475
Font: Font
7576
JumpSound: Sound
7677
}

samples/PlatformerSample/View.fs

Lines changed: 48 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -163,15 +163,10 @@ let view (ctx: GameContext) (model: Model) (buffer: RenderBuffer2D) =
163163
r (int torch.Position.X - 16) (int torch.Position.Y - 32) 32 32
164164

165165
buffer
166-
|> LightDraw.litSprite model.Lighting {
167-
Texture = model.Assets.TorchSheet.Texture
168-
Dest = torchDest
169-
Source = torchSrc
170-
Origin = Vector2.Zero
171-
Rotation = 0.0f
172-
Color = Color.White
173-
Layer = 7<RenderLayer>
174-
}
166+
|> LightDraw.litSprite
167+
model.Lighting
168+
(SpriteState.create(model.Assets.TorchSheet.Texture, torchDest, torchSrc)
169+
|> SpriteState.withLayer 7<RenderLayer>)
175170
|> Draw.drop
176171

177172
// Add occluders
@@ -218,45 +213,34 @@ let view (ctx: GameContext) (model: Model) (buffer: RenderBuffer2D) =
218213
let wy = chunk.Grid.Origin.Y + float32 y * tileSize
219214
let dest = Rectangle(wx, wy, tileSize, tileSize)
220215

221-
buffer
222-
|> LightDraw.litSprite model.Lighting {
223-
Texture = model.Assets.TileTexture
224-
Dest = dest
225-
Source = tileSpriteSrc chunkBiome tile
226-
Origin = Vector2.Zero
227-
Rotation = 0.0f
228-
Color = Color.White
229-
Layer = 10<RenderLayer>
230-
}
231-
|> Draw.drop)
216+
let sprite =
217+
let sprite =
218+
SpriteState.create(
219+
model.Assets.TileTexture,
220+
dest,
221+
tileSpriteSrc chunkBiome tile
222+
)
223+
|> SpriteState.withLayer 10<RenderLayer>
224+
225+
if tile = TileType.Coin then
226+
sprite
227+
|> SpriteState.withNormalMap model.Assets.CoinNormalMap
228+
else
229+
sprite
230+
231+
buffer |> LightDraw.litSprite model.Lighting sprite |> Draw.drop)
232232
chunk.Grid
233233

234-
// Lit player sprite
235-
let playerSrc = AnimatedSprite.currentSource model.PlayerSprite
236-
let mutable playerSrcMut = playerSrc
237-
238-
if model.PlayerSprite.FlipX then
239-
playerSrcMut <-
240-
Rectangle(
241-
playerSrcMut.X,
242-
playerSrcMut.Y,
243-
-playerSrcMut.Width,
244-
playerSrcMut.Height
245-
)
246-
234+
// Lit player sprite (uses litAnimatedSprite for automatic flip/normal map handling)
247235
let playerDrawY = int(model.PlayerPosition.Y + playerHeight - 64.0f)
248236
let playerDest = r (int model.PlayerPosition.X) playerDrawY 64 64
249237

250238
buffer
251-
|> LightDraw.litSprite model.Lighting {
252-
Texture = model.Assets.PlayerSheet.Texture
253-
Dest = playerDest
254-
Source = playerSrcMut
255-
Origin = Vector2.Zero
256-
Rotation = 0.0f
257-
Color = Color.White
258-
Layer = 20<RenderLayer>
259-
}
239+
|> LightDraw.litAnimatedSprite
240+
model.Lighting
241+
20<RenderLayer>
242+
playerDest
243+
model.PlayerSprite
260244
|> Draw.drop
261245

262246
// Particles
@@ -272,26 +256,28 @@ let view (ctx: GameContext) (model: Model) (buffer: RenderBuffer2D) =
272256
// End camera
273257
|> Draw.endCamera 1000<RenderLayer>
274258
// UI
275-
|> Draw.text {
276-
Font = model.Assets.Font
277-
Text =
278-
$"Day/Night Cycle | Time: {model.DayNightTimeOfDay:F1}h | Chunks: {model.Chunks.Count} | Score: {model.Score} | Pos: %.1f{model.PlayerPosition.X},%.1f{model.PlayerPosition.Y} | WASD/Arrows: Move | Space: Jump | R: Respawn"
279-
Position = Vector2(10.0f, 10.0f)
280-
FontSize = 20.0f
281-
Spacing = 1.0f
282-
Color = Color.White
283-
Layer = 1001<RenderLayer>
284-
}
285-
|> Draw.text {
286-
Font = model.Assets.Font
287-
Text =
288-
$"FPS: {model.Diagnostics.Fps} | Frame Time: {model.Diagnostics.FrameTime * 1000.0f:F1}ms"
289-
Position = Vector2(10.0f, 32.0f)
290-
FontSize = 20.0f
291-
Spacing = 1.0f
292-
Color = Color.White
293-
Layer = 1001<RenderLayer>
294-
}
259+
|> Draw.text(
260+
TextState.create(
261+
model.Assets.Font,
262+
$"Day/Night Cycle | Time: {model.DayNightTimeOfDay:F1}h | Chunks: {model.Chunks.Count} | Score: {model.Score} | Pos: %.1f{model.PlayerPosition.X},%.1f{model.PlayerPosition.Y} | WASD/Arrows: Move | Space: Jump | R: Respawn",
263+
Vector2(10.0f, 10.0f)
264+
)
265+
|> TextState.withFontSize 20.0f
266+
|> TextState.withSpacing 1.0f
267+
|> TextState.withColor Color.White
268+
|> TextState.withLayer 1001<RenderLayer>
269+
)
270+
|> Draw.text(
271+
TextState.create(
272+
model.Assets.Font,
273+
$"FPS: {model.Diagnostics.Fps} | Frame Time: {model.Diagnostics.FrameTime * 1000.0f:F1}ms",
274+
Vector2(10.0f, 32.0f)
275+
)
276+
|> TextState.withFontSize 20.0f
277+
|> TextState.withSpacing 1.0f
278+
|> TextState.withColor Color.White
279+
|> TextState.withLayer 1001<RenderLayer>
280+
)
295281
// Minimap
296282
|> Minimap.view ctx model
297283
|> Draw.drop
2.52 MB
Loading

samples/ThreeDSample/MinimapView.fs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -195,15 +195,14 @@ let private viewInner
195195

196196
if minimap.TexReady then
197197
buffer
198-
|> Draw.sprite {
199-
Texture = minimap.Texture
200-
Dest = Rectangle(minimapX, minimapY, minimapSize, minimapSize)
201-
Source = Rectangle(0.0f, 0.0f, float32 texSize, float32 texSize)
202-
Origin = Vector2.Zero
203-
Rotation = 0.0f
204-
Color = Color.White
205-
Layer = 100<RenderLayer>
206-
}
198+
|> Draw.sprite(
199+
SpriteState.create(
200+
minimap.Texture,
201+
Rectangle(minimapX, minimapY, minimapSize, minimapSize),
202+
Rectangle(0.0f, 0.0f, float32 texSize, float32 texSize)
203+
)
204+
|> SpriteState.withLayer 100<RenderLayer>
205+
)
207206
|> Draw.drop
208207

209208
let centerX = minimapX + halfMinimap

0 commit comments

Comments
 (0)