Skip to content

Commit b3868ce

Browse files
committed
[godot] Add SpineSprite3D lighting and shadows
Add opt-in generated lighting, atlas normal maps, PBR controls, and alpha-tested shadow modes for native 3D sprites.
1 parent d4d10d4 commit b3868ce

13 files changed

Lines changed: 1346 additions & 48 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@
316316
- Added `SpineSlotNode3D` for bone following, explicit 3D geometry insertion between Spine batches, and slot material overrides, plus batching statistics and renderer benchmarks.
317317
- Added `SpineSprite3D.camera_relative_depth`, `depth_write_enabled`, `alpha_cutoff`, `get_aabb()`, and `get_depth_shader_code()`, with a custom-shader guide and interactive front/back depth example for module and GDExtension.
318318
- Added explicit single-camera slot-anchor following through `SpineSlotNode3D.depth_camera`, with fixed-depth fallback and diagnostics for invalid cameras and multi-view/physics limitations.
319+
- Added opt-in generated lighting, atlas normal maps, PBR controls, and texture-alpha-tested shadow modes to `SpineSprite3D`, with lazy shadow-only instances and an interactive Raptor lighting example.
319320
- Added `SpineAnimationTrack` support for `SpineSprite3D` and Godot 4 GDExtension, including AnimationPlayer playback/editor scrubbing for both 2D and 3D sprites and a shared timeline example.
320321
- Added an interactive 2D/3D benchmark example with configurable character counts, animation/pause controls, CPU timing and renderer statistics for module and GDExtension.
321322
- Added tint black (two-color tinting) for Godot 4.3+ module and GDExtension runtimes, with animated coin examples and custom shader color attributes. See [#3169](https://github.com/EsotericSoftware/spine-runtimes/issues/3169).
@@ -327,6 +328,7 @@
327328
- Added `SpineTrackEntry.get_additive()` / `set_additive()` for additive blending per track entry.
328329

329330
- **Bug fixes**
331+
- Fixed invalid `SpineSprite3D` preview animation names dereferencing a null track entry.
330332
- Fixed callback-time `time_scale` changes affecting animation and skeleton/physics advances one update late after controller extraction.
331333
- Fixed unrelated scene-tree mutations resetting `SpineSlotNode3D` insertion sorting and revealing inactive-bone children on manual or paused sprites; unchanged descendants retain ownership during transfers.
332334
- Fixed newly allocated `SpineSprite3D` batch instances rendering while hidden, including pool growth and inherited visibility.

‎spine-godot/DEPTH_RENDERING.md‎

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ sprite.camera_relative_depth = true
99
sprite.depth_write_enabled = true
1010
sprite.slot_depth_offset = 0.001
1111
sprite.alpha_cutoff = 0.001
12+
sprite.lighting_enabled = false
13+
sprite.shadow_casting = SpineSprite3D.SHADOW_CASTING_OFF
1214
```
1315

1416
The generated materials are unlit and double-sided by default. Each attachment gets a small local Z offset according to its position in the **applied Spine draw order**. From behind, the vertex shader flips that offset, putting later attachments nearer the camera again. Attachment, triangle and batch submission order do not reverse. This preserves the authored layering from both sides, rather than revealing the back of a physical stack. It does not turn planar artwork into a volumetric character.
@@ -32,16 +34,71 @@ These depth-write settings control **generated materials**. Custom materials ret
3234

3335
The regression covers default `pixel_size = 0.01` with a `0.001` gap, and a larger 48-unit-wide fixture viewed from 60 units away with a `0.01` gap. On the tested Compatibility backend, the latter needed more than `0.001` once vertex displacement was active. Tune spacing and camera clipping ranges for your scene. Larger gaps make the layered construction more visible and increase the position jump when the camera crosses edge-on.
3436

35-
Alpha fades and soft atlas edges also deserve testing: fragments disappear at the cutoff, and surviving translucent fragments can occlude later geometry behind them. This is not order-independent transparency. Lighting and shadow support are separate from this depth policy; generated Spine materials currently cast no shadows.
37+
Alpha fades and soft atlas edges also deserve testing: fragments disappear at the cutoff, and surviving translucent fragments can occlude later geometry behind them. This is not order-independent transparency.
38+
39+
## Generated lighting, normal maps, and shadows
40+
41+
Lighting is opt-in so existing scenes keep their authored unlit appearance:
42+
43+
```gdscript
44+
sprite.lighting_enabled = true
45+
sprite.normal_map_enabled = true
46+
sprite.normal_map_flip_y = true
47+
sprite.normal_scale = 1.0
48+
sprite.specular = 0.25
49+
sprite.roughness = 0.65
50+
sprite.metallic = 0.0
51+
```
52+
53+
Generated lit materials use the attachment plane's normal when no normal map exists. The runtime supplies standard mesh normals and UV-derived tangents, so atlas pages with imported normal maps can use Godot's compressed tangent-space normal-map path. Normal-map loading is not a separate 3D path: configure the atlas importer's existing normal-map prefix (for example, `n` resolves `n_raptor.png`) and `SpineAtlasResource` supplies the matching page texture. `normal_map_flip_y` defaults on for the convention used by the bundled Raptor asset; disable it for maps authored with the opposite Y convention. Example19 starts the high-contrast Raptor map at `normal_scale = 0.25`; tune strength for each asset and light rig. Animated normal/PBR controls are uniforms and do not rebuild mesh buffers or duplicate warmed materials.
54+
55+
Generated shadow casting is also opt-in:
56+
57+
```gdscript
58+
sprite.shadow_casting = SpineSprite3D.SHADOW_CASTING_DOUBLE_SIDED
59+
sprite.shadow_alpha_cutoff = 0.3
60+
```
61+
62+
Modes are **Off**, **On**, **Double-Sided**, and **Shadows Only**. Generated shadows use dedicated shadows-only RenderingServer instances and alpha-tested materials. They share the visible batch meshes and are allocated lazily on first use; turning shadows off hides and retains the warmed pool. `shadow_alpha_cutoff` is independent of visible `alpha_cutoff`, includes skeleton/slot/attachment tint alpha, and can be animated without creating a material for each value. Lighting does not need to be enabled for a generated material to cast shadows.
63+
64+
`camera_relative_depth` still operates per rendering camera for visible generated shaders. A shadow map has the light's view, so test extreme slot spacing, culling, and lights crossing the planar character in your scene. The alpha-tested shadow is a planar silhouette, not volumetric self-shadowing.
3665

3766
## Custom Spine materials
3867

3968
No user shader source is rewritten. Every cached `ShaderMaterial` pass receives these reserved uniforms from the sprite:
4069

4170
- `spine_texture`: current atlas-page texture.
4271
- `spine_premultiplied_alpha`: the atlas page's PMA flag.
72+
- `spine_normal_texture`: current atlas-page normal map, when one was imported.
73+
- `spine_has_normal_texture`: whether the current page has a normal map.
4374
- `spine_camera_relative_depth`: the sprite's camera-relative-depth flag.
44-
- `spine_alpha_cutoff`: the sprite's alpha cutoff.
75+
- `spine_alpha_cutoff`: the sprite's visible alpha cutoff.
76+
- `spine_normal_map_enabled`, `spine_normal_map_flip_y`, and `spine_normal_scale`.
77+
- `spine_specular`, `spine_roughness`, and `spine_metallic`.
78+
79+
Custom shaders receive light tint in `CUSTOM0`, dark tint in `CUSTOM1`, and standard mesh `NORMAL`/`TANGENT` attributes. They remain authored contracts: the runtime never inserts lighting, normal-map, blending, culling, depth, or shadow code into their source. Custom batches cast directly according to `shadow_casting`, so the shader's authored alpha/scissor and culling behavior controls its shadow pass; `shadow_alpha_cutoff` is only for generated shadow materials.
80+
81+
Use Godot's normal-map outputs in a custom lit shader:
82+
83+
```glsl
84+
uniform sampler2D spine_normal_texture : hint_normal, repeat_disable;
85+
uniform bool spine_has_normal_texture = false;
86+
uniform bool spine_normal_map_enabled = true;
87+
uniform bool spine_normal_map_flip_y = true;
88+
uniform float spine_normal_scale = 1.0;
89+
90+
void fragment() {
91+
// Set ALBEDO and ALPHA first.
92+
if (spine_normal_map_enabled && spine_has_normal_texture) {
93+
vec3 mapped_normal = texture(spine_normal_texture, UV).rgb;
94+
if (spine_normal_map_flip_y) mapped_normal.g = 1.0 - mapped_normal.g;
95+
NORMAL_MAP = mapped_normal;
96+
NORMAL_MAP_DEPTH = spine_normal_scale;
97+
}
98+
}
99+
```
100+
101+
Do not manually rely on the sampled blue channel: Godot may import normal maps with two-channel compression and reconstruct Z internally when `NORMAL_MAP` is used. `NORMAL_MAP_DEPTH = 0.0` restores the flat normal.
45102

46103
Template values for reserved uniforms do not override the sprite. Other uniforms remain template-owned. Reassign a template after changing its parameters to refresh cached copies. Every visible custom pass, including `next_pass`, needs to implement the policy if it should match the generated geometry. A pass that ignores it retains physical vertex positions and can hide or detach facial details from the back. Non-shader next passes do not acquire custom vertex logic automatically.
47104

@@ -132,3 +189,5 @@ Native mesh culling bounds and `sprite.get_aabb()` include both possible depth d
132189
Editor click/box picking conservatively includes attachment triangles at both possible depth positions. It may select a triangle at the currently unused position, especially with exaggerated spacing. It does not follow texture alpha, arbitrary custom shader deformation, or gameplay collision shapes.
133190

134191
Run `examples/18-depth-offset-orbit/depth-offset-orbit.tscn` in either Godot 4 example project. It uses the native sprite properties and generated materials. Compare the front/back toggle, then use **Exaggerate gap**, **Marker follows flip**, and **Face shader ignores flip** to inspect the child/custom-material contracts. The default gap can be restored with **Gap 0.001**.
192+
193+
Run `examples/19-3d-lighting/3d-lighting.tscn` to inspect generated lighting, the Raptor atlas normal map, moving omni-light response, normal strength/Y convention, and texture-alpha shadows on opaque ground.

‎spine-godot/example-v4-extension/examples/15-spine-sprite-3d/spine-sprite-3d.tscn‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ skeleton_data_res = ExtResource("2")
6060
pixel_size = 0.007
6161
render_priority = -48
6262
cull_mode = 1
63+
lighting_enabled = true
64+
shadow_casting = 2
6365
preview_skin = "Default"
6466
preview_animation = "walk"
6567
preview_frame = true
@@ -77,6 +79,8 @@ rotation = Vector3(0, -0.32, 0)
7779
skeleton_data_res = ExtResource("2")
7880
pixel_size = 0.007
7981
render_priority = 48
82+
lighting_enabled = true
83+
shadow_casting = 2
8084
preview_skin = "Default"
8185
preview_animation = "hoverboard"
8286
preview_frame = true
@@ -87,6 +91,8 @@ position = Vector3(0, 1.5, 0.25)
8791
skeleton_data_res = ExtResource("3")
8892
pixel_size = 0.004
8993
slot_depth_offset = 0.0002
94+
lighting_enabled = true
95+
shadow_casting = 2
9096
preview_skin = "Default"
9197
preview_animation = "animation"
9298
preview_frame = true
@@ -98,5 +104,5 @@ offset_top = 20.0
98104
offset_right = 1128.0
99105
offset_bottom = 76.0
100106
theme_override_font_sizes/font_size = 20
101-
text = "Native SpineSprite3D — batched geometry, tint black, and a lit 3D sphere inserted at the hand slot\nThe light affects the sphere, orange box, and ground; generated Spine materials are unlit and cast no shadows."
107+
text = "Native SpineSprite3D — batched geometry, tint black, lighting, and alpha-cutout shadows\nGodot lights affect Spine and inserted 3D geometry; Spineboy uses flat normals."
102108
horizontal_alignment = 1
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
extends Node3D
2+
3+
@onready var raptor: SpineSprite3D = $Raptor
4+
@onready var light: OmniLight3D = $OmniLight3D
5+
var auto_light: CheckButton
6+
var lighting: CheckButton
7+
var normal_map: CheckButton
8+
var flip_normal_y: CheckButton
9+
var shadows: CheckButton
10+
var normal_scale: HSlider
11+
var shadow_cutoff: HSlider
12+
var status: Label
13+
var light_angle := 0.0
14+
15+
func _ready():
16+
$Camera3D.look_at(Vector3(0, 0.4, 0))
17+
var animation_state := raptor.get_animation_state()
18+
if animation_state != null:
19+
animation_state.set_animation("walk", true, 0)
20+
create_controls()
21+
update_status()
22+
23+
func _process(delta: float):
24+
if auto_light != null and auto_light.button_pressed:
25+
light_angle = fposmod(light_angle + delta * 35.0, 360.0)
26+
var radians := deg_to_rad(light_angle)
27+
light.position = Vector3(cos(radians) * 3.5, 2.5 + sin(radians * 0.5), 4.0)
28+
update_status()
29+
30+
func create_controls():
31+
var panel := PanelContainer.new()
32+
panel.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
33+
$UI.add_child(panel)
34+
var margin := MarginContainer.new()
35+
for side in ["left", "top", "right", "bottom"]:
36+
margin.add_theme_constant_override("margin_" + side, 12)
37+
panel.add_child(margin)
38+
var rows := VBoxContainer.new()
39+
margin.add_child(rows)
40+
var title := Label.new()
41+
title.text = "SpineSprite3D — generated lighting, atlas normal maps, and shadows"
42+
title.add_theme_font_size_override("font_size", 22)
43+
rows.add_child(title)
44+
var controls := HFlowContainer.new()
45+
rows.add_child(controls)
46+
auto_light = add_toggle(controls, "Move light", true)
47+
lighting = add_toggle(controls, "Lighting", raptor.lighting_enabled)
48+
normal_map = add_toggle(controls, "Normal map", raptor.normal_map_enabled)
49+
flip_normal_y = add_toggle(controls, "Flip normal Y", raptor.normal_map_flip_y)
50+
shadows = add_toggle(controls, "Shadows", raptor.shadow_casting != SpineSprite3D.SHADOW_CASTING_OFF)
51+
var normal_scale_label := Label.new()
52+
normal_scale_label.text = "Normal strength"
53+
controls.add_child(normal_scale_label)
54+
normal_scale = HSlider.new()
55+
normal_scale.custom_minimum_size.x = 90
56+
normal_scale.min_value = 0
57+
normal_scale.max_value = 3
58+
normal_scale.step = 0.05
59+
normal_scale.value = raptor.normal_scale
60+
controls.add_child(normal_scale)
61+
var shadow_cutoff_label := Label.new()
62+
shadow_cutoff_label.text = "Shadow cutoff"
63+
controls.add_child(shadow_cutoff_label)
64+
shadow_cutoff = HSlider.new()
65+
shadow_cutoff.custom_minimum_size.x = 90
66+
shadow_cutoff.min_value = 0
67+
shadow_cutoff.max_value = 1
68+
shadow_cutoff.step = 0.01
69+
shadow_cutoff.value = raptor.shadow_alpha_cutoff
70+
controls.add_child(shadow_cutoff)
71+
status = Label.new()
72+
rows.add_child(status)
73+
var note := Label.new()
74+
note.text = "Normal map: assets/raptor/n_raptor.png (loaded by the atlas importer). The opaque ground receives the texture-alpha shadow."
75+
rows.add_child(note)
76+
lighting.toggled.connect(func(enabled): raptor.lighting_enabled = enabled; update_status())
77+
normal_map.toggled.connect(func(enabled): raptor.normal_map_enabled = enabled; update_status())
78+
flip_normal_y.toggled.connect(func(enabled): raptor.normal_map_flip_y = enabled; update_status())
79+
shadows.toggled.connect(func(enabled):
80+
raptor.shadow_casting = SpineSprite3D.SHADOW_CASTING_DOUBLE_SIDED if enabled else SpineSprite3D.SHADOW_CASTING_OFF
81+
update_status())
82+
normal_scale.value_changed.connect(func(value): raptor.normal_scale = value; update_status())
83+
shadow_cutoff.value_changed.connect(func(value): raptor.shadow_alpha_cutoff = value; update_status())
84+
85+
func add_toggle(parent: Control, text: String, enabled: bool) -> CheckButton:
86+
var button := CheckButton.new()
87+
button.text = text
88+
button.button_pressed = enabled
89+
parent.add_child(button)
90+
return button
91+
92+
func update_status():
93+
if status == null:
94+
return
95+
status.text = "Normal strength %.2f | shadow cutoff %.2f | light (%.1f, %.1f, %.1f)" % [
96+
raptor.normal_scale, raptor.shadow_alpha_cutoff, light.position.x, light.position.y, light.position.z]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
[gd_scene load_steps=6 format=3]
2+
3+
[ext_resource type="Script" path="res://examples/19-3d-lighting/3d-lighting.gd" id="1"]
4+
[ext_resource type="SpineSkeletonDataResource" path="res://assets/raptor/raptor-data.tres" id="2"]
5+
6+
[sub_resource type="Environment" id="Environment"]
7+
background_mode = 1
8+
background_color = Color(0.08, 0.095, 0.12, 1)
9+
ambient_light_source = 3
10+
ambient_light_color = Color(0.25, 0.3, 0.4, 1)
11+
ambient_light_energy = 0.25
12+
13+
[sub_resource type="StandardMaterial3D" id="GroundMaterial"]
14+
albedo_color = Color(0.12, 0.16, 0.22, 1)
15+
roughness = 0.9
16+
17+
[sub_resource type="PlaneMesh" id="Ground"]
18+
material = SubResource("GroundMaterial")
19+
size = Vector2(14, 12)
20+
21+
[node name="SpineSprite3DLighting" type="Node3D"]
22+
script = ExtResource("1")
23+
24+
[node name="WorldEnvironment" type="WorldEnvironment" parent="."]
25+
environment = SubResource("Environment")
26+
27+
[node name="Camera3D" type="Camera3D" parent="."]
28+
position = Vector3(0, 2.5, 10)
29+
current = true
30+
31+
[node name="Raptor" type="SpineSprite3D" parent="."]
32+
position = Vector3(0, -1.35, 0)
33+
skeleton_data_res = ExtResource("2")
34+
pixel_size = 0.006
35+
lighting_enabled = true
36+
normal_map_enabled = true
37+
normal_map_flip_y = true
38+
normal_scale = 0.25
39+
specular = 0.25
40+
roughness = 0.65
41+
shadow_casting = 2
42+
shadow_alpha_cutoff = 0.3
43+
preview_skin = "Default"
44+
preview_animation = "walk"
45+
preview_frame = true
46+
preview_time = 0.2
47+
48+
[node name="Ground" type="MeshInstance3D" parent="."]
49+
position = Vector3(0, -1.5, -1)
50+
mesh = SubResource("Ground")
51+
52+
[node name="OmniLight3D" type="OmniLight3D" parent="."]
53+
position = Vector3(3.5, 2.5, 4)
54+
light_energy = 5.0
55+
omni_range = 20.0
56+
shadow_enabled = true
57+
58+
[node name="UI" type="CanvasLayer" parent="."]

‎spine-godot/example-v4/examples/15-spine-sprite-3d/spine-sprite-3d.tscn‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ skeleton_data_res = ExtResource("2")
6060
pixel_size = 0.007
6161
render_priority = -48
6262
cull_mode = 1
63+
lighting_enabled = true
64+
shadow_casting = 2
6365
preview_skin = "Default"
6466
preview_animation = "walk"
6567
preview_frame = true
@@ -77,6 +79,8 @@ rotation = Vector3(0, -0.32, 0)
7779
skeleton_data_res = ExtResource("2")
7880
pixel_size = 0.007
7981
render_priority = 48
82+
lighting_enabled = true
83+
shadow_casting = 2
8084
preview_skin = "Default"
8185
preview_animation = "hoverboard"
8286
preview_frame = true
@@ -87,6 +91,8 @@ position = Vector3(0, 1.5, 0.25)
8791
skeleton_data_res = ExtResource("3")
8892
pixel_size = 0.004
8993
slot_depth_offset = 0.0002
94+
lighting_enabled = true
95+
shadow_casting = 2
9096
preview_skin = "Default"
9197
preview_animation = "animation"
9298
preview_frame = true
@@ -98,5 +104,5 @@ offset_top = 20.0
98104
offset_right = 1128.0
99105
offset_bottom = 76.0
100106
theme_override_font_sizes/font_size = 20
101-
text = "Native SpineSprite3D — batched geometry, tint black, and a lit 3D sphere inserted at the hand slot\nThe light affects the sphere, orange box, and ground; generated Spine materials are unlit and cast no shadows."
107+
text = "Native SpineSprite3D — batched geometry, tint black, lighting, and alpha-cutout shadows\nGodot lights affect Spine and inserted 3D geometry; Spineboy uses flat normals."
102108
horizontal_alignment = 1

0 commit comments

Comments
 (0)