some fun shader insights. also, pain.

Thu 06-25-2026

pain and fun. what better combo is there?

Here's a pleasant clip of something from my game, Stories of Somnia before things get unpleasant.


I just spent three hours fighting Godot and I need to talk about it before the rage leaves my body and I lose the lesson along with it.

Here's the setup: I have a custom sprite node that needs to show something 2D in a 3D World. In Godot, this is done with a SubViewport. Simple. Standard. People do this all the time. So naturally Godot decided this was the hill to make me die on.

Every. Single. Time. It launched the game and created a new scene, I'd get an error (despite everything working perfectly). And today I said, I'm done with your shit, Godot.

Here's how it looks in-editor:

alt text

Really simple, but you'd be surprised how complicated the front end gets:

This shader adds a recolor + outline + glowing noise texture on top of this guy, who is usually this color without all the fancy stuff:

And yes, I am pretentious enough to call my node BetterSprite3D. Because, well, frankly... It is. At least for my 2.5D game where I need fancy recolors, scrolling textures and other stuff:

This is all done with base textures that I never touch or re-animate or anything. All of this is done live, which means I can recolor sprites on-the-fly too! Super versatile, and mostly the result of my lazy butt not wanting to commit to one color of a dude who players will see for 30 seconds approximately before completely obliterating them.


ANYWHO, back to the point: insanity because I don't like seeing errors in the console when I'm trying to actually check for real errors not drama queens.

I spent about 4 hours trying to fix texture issues. The problem is that Godot's materials are built for reusability, so when you try to make them unique per-mesh things get complicated really quick. Here's the code for the BetterAnimatedSprite3D:

@icon("res://Resources/Textures/2D/UI/node icons/node_3D/icon_hitbox.png")
@tool
class_name BetterAnimatedSprite3D
extends BetterSprite

@export_group("Pointers")
@export var my_animated_sprite_2d : AnimatedSprite2D
@export var my_viewport : SubViewport
@export var my_mesh : MeshInstance3D

@export_category("Features")
@export var material_override_3d : StandardMaterial3D:
    set(value):
        material_override_3d = value
@export var material_overlay_3d : Material:
    set(value):
        material_overlay_3d = value
        my_mesh.material_overlay = value
@export var material_override_2d : Material:
    set(value):
        if !Engine.is_editor_hint():
            material_override_2d = value.duplicate()
        else:
            material_override_2d = value

        my_animated_sprite_2d.material = material_override_2d
@export var transparency_type : BaseMaterial3D.Transparency = BaseMaterial3D.TRANSPARENCY_ALPHA_SCISSOR:
    set(value):
        transparency_type = value
        var mat : StandardMaterial3D = _material() as StandardMaterial3D
        if !mat:
            return
        mat.transparency = value
@export var distance_fade_mode : BaseMaterial3D.DistanceFadeMode = BaseMaterial3D.DistanceFadeMode.DISTANCE_FADE_DISABLED:
    set(value):
        var mat : Material = _material()
        if mat:
            distance_fade_mode = value
            mat.distance_fade_mode = value
@export_range(0.0,50.0,0.1) var distance_fade_min : float = 2.0:
    set(value):
        if distance_fade_mode == BaseMaterial3D.DistanceFadeMode.DISTANCE_FADE_DISABLED:
            return
        var mat : Material = _material()
        if mat:
            distance_fade_min = value
            mat.distance_fade_min_distance = value
@export_range(0.0,50.0,0.1) var distance_fade_max : float = 3.5:
    set(value):
        if distance_fade_mode == BaseMaterial3D.DistanceFadeMode.DISTANCE_FADE_DISABLED:
            return
        var mat : Material = _material()
        if mat:
            distance_fade_max = value
            mat.distance_fade_max_distance = value

@export_category("Emission")
@export var emission_color : Color = Color.WHITE:
    set(value):

        if value == Color.BLACK:
            return

        emission_color = value

        if emission == 0.0:
            pass

        if my_animated_sprite_2d:
            if emission != 0.0:
                my_animated_sprite_2d.modulate = value
            else:
                my_animated_sprite_2d.modulate = Color.WHITE

        var mat : Material = _material()

        if mat and mat is StandardMaterial3D:
            mat.emission = value
        else:
            push_error("No material found for set_emission_color!")
@export_range(0.0,100.0,0.1) var emission : float = 0.0:
    set(value):

        emission = value

        var mat : Material = _material()

        if mat and mat is StandardMaterial3D:
            mat.emission_energy_multiplier = value
            mat.emission_enabled = (emission != 0.0)
            emission_color = emission_color #Update to stored info
        else:
            push_error("No material found for set_emission!")

@export_category("AnimatedSprite3D")
@export var sprite_frames : SpriteFrames = null:
    set(value):
        sprite_frames = value
        if !sprite_frames:
            animation = ""
            frame = 0
        _update(my_animated_sprite_2d,"sprite_frames")
@export var animation: StringName:
    set(value):

        if sprite_frames:
            animation = value
            if sprite_frames.has_animation(value) and my_animated_sprite_2d.sprite_frames.has_animation(value):
                _update(my_animated_sprite_2d, "animation")
        else:
            animation = ""
@export var play_animation : bool = false:
    set(value):
        play_animation = value
        if value:
            my_animated_sprite_2d.play(animation,speed_scale)
        else:
            my_animated_sprite_2d.stop()
@export var frame : int = 0:
    set(value):
        frame = value
        my_animated_sprite_2d.set_frame(value)
        #if sprite_frames and animation:
            #_update(my_animated_sprite_2d,"frame")
            #frame = clamp(value,0,sprite_frames.get_frame_count(animation) - 1)
        #else:
            #frame = 0
@export var speed_scale : float = 1.0:
    set(value):
        speed_scale = value
        _update(my_animated_sprite_2d,"speed_scale")

@export_group("SpriteBase3D")
@export var offset : Vector2 = Vector2.ZERO:
    set(value):
        offset = value
        if my_mesh and my_viewport:
            my_mesh.position.x = offset.x * 0.01 # Pixel conversion
            my_mesh.position.y = offset.y * 0.01 # Pixel conversion
        _update()
@export var flip_h : bool = false:
    set(value):
        flip_h = value
        _update(my_animated_sprite_2d,"flip_h")
@export var flip_v : bool = false:
    set(value):
        flip_v = value
        _update(my_animated_sprite_2d,"flip_v")
@export_range(0.0,1.0,0.01) var transparency : float = 0.0:
    set(value):
        transparency = value
        my_mesh.transparency = value
        _update()
@export var no_depth_test : bool = false:
    set(value):
        no_depth_test = value
        if my_mesh:
            _material().no_depth_test = value
@export var sorting_offset : int = 0:
    set(value):
        sorting_offset = value
        my_mesh.sorting_offset = value
        _update()
@export var stencil_mode : BaseMaterial3D.StencilMode = BaseMaterial3D.StencilMode.STENCIL_MODE_DISABLED:
    set(value):
        stencil_mode = value
        if my_mesh:
            _material().stencil_mode = value
@export var stencil_color : Color = Color.WHITE:
    set(value):
        if stencil_mode in [BaseMaterial3D.StencilMode.STENCIL_MODE_OUTLINE,BaseMaterial3D.StencilMode.STENCIL_MODE_XRAY]:
            stencil_color = value
            if my_mesh:
                _material().stencil_color = value

var emission_tween : Tween
enum emission_type {
    PULSE_IN,
    PULSE_OUT
}
...

Anyways I spent about 4 hours screaming into the void all to find out I didn't need a special solution and Godot had a built-in one:

Viewport.get_texture()

Let this be your reminder if you ever need ViewportTextures created on-the-fly. Cause I really reinvented the wheel. Like 14 times. And thought I was smart.

In my experience, two years in, all programming problems go like this:

Simple > Complicated > Simple

But, what do I know? I'm just a guy building a game, screaming into the void.