Skip to content

Instantly share code, notes, and snippets.

@EtiTheSpirit
Last active April 6, 2025 20:19
Show Gist options
  • Save EtiTheSpirit/655d8e81732ba516ca768dbd7410ddf4 to your computer and use it in GitHub Desktop.
Save EtiTheSpirit/655d8e81732ba516ca768dbd7410ddf4 to your computer and use it in GitHub Desktop.
Rain World Shader Documentation

This addresses quirks with the lighting engine and how it interacts with custom shaders.

Futile is a bit of a frankenstein's monster when it comes to iteroperability with base Unity systems; while Unity shaders work, the behavior is overshadowed by Futile's layer system. This caused me to spend more hours than I care to admit trying to figure out why an unlit holographic/additive shader I wrote was casting shadows, and I want to prevent that pain for anyone else who may be reading.

Custom Shaders and Futile's Render Queue

This section covers shader information. It is directed towards those that already understand fundamental shader concepts.

Quirks and Limitations

  • The ZWrite and ZTest commands (among less common others) are ignored. Futile does not have a depth buffer.1
    • Vanilla uses certain declarations that are useless as well (their inclusion is erroneous). These are:
      • Lighting (seen as Lighting Off) does nothing: Shaders made with manually declared vertex/fragment programs are never subjected to default lighting (you need to write that in yourself).
      • Fog (seen asFog { Color(0,0,0,0) }) does nothing: The vanilla shaders don't declare #pragma multi_compile_fog so Unity fog can't apply.
      • BindChannels declaration. This is an artifact of extremely old shader code and bindings are now done in the appdata struct itself.
      • Category block. This is only really useful if there's more than one subshader with common settings.
      • Declaring register(s0) on the grab texture. This is another artifact of extremely old shader code and is effectively useless in the face of modern HLSL spec.
  • ALL shader tags are ignored.
    • Futile uses the sortZ property of FNode to order sprite rendering instead, alongside the layer system (see RoomCamera.ReturnFContainer); you cannot use the Queue tag.
      • FContainers (layers) are rendered sequentially, and each sprite within is rendered based on its sortZ property (higher = later). For more information on what layers are available and in what order, see RoomCamera..ctor
      • sortZ may not apply automatically, see FContainer.shouldSortByZ (if false, you will need to call SortByZ() manually). Any sprites added after this function call will draw on top regardless of their sort value, so ensure this is called after the last sprite is added!
      • sortZ exists purely within its own container and can not be used to draw on top of or below other containers.
  • Black is pseudo-transparency. In Futile, black (0, 0, 0) has a special behavior that causes it to grab the pixel from the layer below and copy its color. This creates the illusion of transparency, without actually having a transparent pixel (hence the term "pseudo-transparency"). This is used for various effects, such as Pebbles's halo.
    • If you need to render black, consider using a value of 0.004, which is as close to black as you can sensibly get in an 8 bit color setup.
  • The stencil buffer works iff it is enabled.1
  • The Cull command does work, but is effectively useless.
  • Blend modes do work, and apply to the current state of the entire frame buffer as one might expect (this includes applying on top of lower layers).
    • The default (or at least, expected) blend mode for shaders is Blend SrcAlpha OneMinusSrcAlpha.
    • Translucent objects may not work as expected with lighting, see the next section.
  • FContainer does have a sortZ property as it inherits from FNode, but whether or not this is functional has not been tested; it is not used in vanilla and so leveraging it would require setup ahead of time.
1 It is possible to forcefully add a depth and stencil buffer. To do this, you need to modify the depth bits of the main render texture (see FScreen::renderTexture, set to 16 for depth buffer, 24 for depth and stencil). Whether or not the depth buffer works is unknown, but the stencil buffer does absolutely work. A toolkit I will be releasing SoonTM will include a function to cleanly do this from any mod, and a PR has been submitted to RegionKit so it may be included there as well.

Uniforms and variables.

Rain World populates a number of uniforms, as well as other shader properties. This list may not be complete, but is as complete as possible. Some entries are not documented if their purpose is self-explanatory or unknown. Consider searching for strings in DNSpy/etc. when figuring out what these do.

Important values ahead of time

  • "The room's size in pixels", or what I will call roomSize, indicates the room's size measured in tiles, times 20 (a tile is 20x20 pixels). Thus, this includes out of bounds tiles as well. Effectively, if it can be set in the level editor, it counts here.
  • "The camera position", or what I will call camPos, refers to an interpolated, runtime camera position. This value can change in realtime!
  • "The camera position as set by the room", or what I will call fileCamPos, refers to the literal camera position that was set in the level editor. This value is constant.

Keywords

  • Shader Keywords are as follows (REMEMBER: To use these, you need to disable shader variant stripping when exporting your assets!)

    • SNOW_ON - If it's snowing right now.
      • SNOW_OFF is declared as well, but the keyword is never disabled. You should not use this. Use SNOW_ON.
    • HR - Enters spoiler territory, but if you know you know.
    • Gutter - If the room has the DirtyWater effect (notably, this breaks convention in that it is not all caps!)
  • Shader Uniforms are as follows:

    • uniform float _RAIN - Global timer, analogous to (but not the same as) _Time.y. It is affected by timescale. The value is shadersTime / 5.0f where shadersTime has Time.deltaTime * timescale added to it every frame.
    • uniform float2 _screenOffset - This is representative of an offset for displaying pixels. Outside of OpenGL environments, and when the game's render scale is not 1x (which it always is, unless a mod changes that), this value is half of a pixel in normalized space.
    • uniform float2 _screenSize - Analogous to _ScreenParams.xy in native Unity HLSL. This value is the current resolution of the displayed screen (this means mods like Sharpener, while they do make the window larger, do not affect this value).
    • uniform float4 _spriteRect - Used in the room camera. See RoomCamera::DrawUpdate. This value is particularly complicated to compute:
      • It begins with the interpolated camera position.
      • Screen shake is applied based on a random normalized vector, but...
      • If the game is not in the Void Sea, this position gets clamped to ±20px relative to the position of the camera declared by the room itself, and...
      • If the game is in the Void Sea, there is a constant -528px offset applied to the Y axis, unless the water is upside down (comes down from the ceiling, as seen in HR), from which there is a constant offset of (the room's height in pixels) + 128px on the Y axis.
      • The vector is then floored, and has 0.02 subtracted from both components. xy = floor(xy) - 0.02
      • The vector is offset by the camera's offset field.
      • The vector is offset by the camera's hardLevelGfxOffset field.
      • A Vector4 is constructed to send to the shader, where...
      • xy is set to (vector - float2(0.5, -0.5) + roomCameraPosition) / _screenSize
      • zw is set to (vector - float2(0.5, -0.5) + levelGraphic.sizeXY) / _screenSize
    • uniform float4 _camInRoomRect - This is the position of the camera relative to the room. The Vector2 from _spriteRect is used here as well, and it is computed as:
      • xy = vector / roomSize
      • zw = _screenSize / roomSize
    • uniform float2 _WorldCamPos - This is used in class RoofTopView and in the Void Sea scene. It is unset otherwise, and thus should not be used for shaders outside of this context.
      • In the void sea scene, it is computed as camPos - scene.sceneOrigo + scene.cameraOffset (camPos is the Vector2 from _spriteRect again, and sceneOrigo and cameraOffset are members of the scene class itself).
      • In the RoofTopView class, this is used when drawing the floor, and is camPos - scene.sceneOrigo.
    • uniform float2 _PlayerPos - The player's position normalized to the width and height of the room. (this will be between 0 and 1, a value of out of that range indicates the player has gone out of bounds).
    • uniform float _Grime - Identical to the grime value as set by the room settings, as a % from 0 to 1.
    • uniform float _WetTerrain - Identical to the wet terrain value as set by the room settings, as a % from 0 to 1.
    • uniform float _SwarmRoom - 0f in normal rooms, and 1f in batfly hive rooms (aka swarm rooms)
    • uniform float _fogAmount - The strength of the fog effect, if present. 0 otherwise.
    • uniform float _windAngle - Used in the blizzard graphics. This is not used in any blizzard shaders though, which is a bit perplexing.
    • uniform float _windDir - Same as above, used in the blizzard graphics class, but not implemented in any blizzard shaders.
    • uniform float _windStrength - Same as above
    • uniform float _snowStrength - Same as above
    • uniform float _waterTime - Same as above
    • uniform float _rimFix - This value is used to slightly offset the displayed level graphics. Normally 0, but 1 when the room effect is AboveCloudsView or RoofTopView
    • uniform float _DustWaveProgress - Represents the intensity of a dust storm, as a value from 0 to 1.
    • uniform float2 _gridOffset - Used in the superstructure grid vfx (general systems bus), the name should be self explanatory.
    • uniform float _rainDirection - Used to tilt the rain slightly.
    • uniform float4 _RainSpriteRect - Used to scale the rain sprite to the room.
      • xy is camPos / roomSize
      • zw is the game's default resolution (_x768) divided by roomSize, with a catch for that first blank on the X size:
      • If Remix is enabled, it uses the screen width as reported by the camera. If remix is not enabled, it uses a constant 1366f.
      • w is not affected by this rule and is always 768 / roomSize.y.
    • uniform float _rainIntensity - The intensity of the rain overlay, from 0 to 1.
    • uniform float _rainEverywhere - Determines if rain should be drawn over the entire screen, or just in areas that are not under something, as a value from 0 to 1.
    • uniform float4 _AboveCloudsAtmosphereColor - Used in RoofTopView and AboveCloudsView to draw the sky.
    • uniform float4 _MultiplyColor - This is provided in the same context as the cloud color above. This is used when transitioning into or out of dusk.
    • uniform float _waterDepth - Used to determine where to draw room water, where 0 is in the foreground (over everything) and 1 is in the background (behind everything).
    • uniform float _waterLevel - The height of the water in the room, in pixels.
    • uniform float4 _lightDirAndPixelSize - Computed as float4(room.lightAngle.xy, 1f / 1400f, 1f / 800f). The "pixel size" is always that constant. This constant is used in rooms as well.
    • uniform float4 _MapCol - Used to color the map overlay in the HUD. This is only set upon loading the game once, and it is uncertain what changing it during runtime will do (though it will probably do what you expect it to do).
    • uniform float4 _MapWaterCol - The color of water in the map, again only set upon loading the game once.
    • uniform float2 _mapPan - The offset of the map as the user moves through it, measured in pixels.
    • uniform float2 _mapSize - The size of the map texture, oddly computed as float2(_mapTexture.width, _mapTexture.height / 3);
    • uniform float4 _transitionColor - Set in the RoomTransition class, this is a faded color based on the time between frames presumably when transitioning between rooms.
    • uniform float _BlurDepth - Computed as 1 + focus * 9 where focus is a member of MenuScene, and is interpolated.
    • uniform float _BlurRange - Interpolated between blurMin and blurMax of MenuScene. The interpolation factor is saturate(pow(max(0, focus), 0.75)).
    • uniform float2 _MenuCamPos - Used for the fake depth effect in the main menu.
    • uniform float4 _tileCorrection - Used in the context of blizzards and dust.
      • x is _screenSize.x / roomSize.x * (1366 / _screenSize.x) * 1.02f
      • y is _screenSize.y / roomSize.y * 1.04f
      • zw is fileCamPos / roomSize
    • uniform float4 _EnergyCellCoreCol - Used in EnergyCell this is just the color of the center of the cell. This changes based on how long has been used for.
    • uniform float _hologramThreshold - This value is 0.5 if the render scale is larger than 1x, and 0.65 otherwise.
    • uniform float2 _SceneOrigoPosition - Used in RoofTopView, this provides the scene's sceneOrigo property to shaders.
    • uniform float4 _LeviathanColorA - Used in BigEelGraphics, likely an artifact of older code as this type of property is not seen in other creatures (which instead just directly set the color of the sprites).
    • uniform float4 _LeviathanColorB - Same as above.
    • uniform float4 _LeviathanColorHead - Same as above.
    • sampler2D _LevelTex - The raw image of the level itself, the one rendered by the editor app.
    • sampler2D _PalTex - The current color palette's texture.
    • sampler2D _mapFogTexture - Used to hide the map when first opening it, causes that fade in effect where it grows as you remember more of it.
    • sampler2D _WindTex - Used when generating the wind map of a blizzard. It is a render texture that is written to by a shader to store data.
    • sampler2D _WindTexRendered - The same as _WindTex but its variable name refers to it as "interpolated".
    • sampler2D _Original - Used in the context of class FlowMap
    • sampler2D _DustFlowTex - Not investigated, presumably used similarly to the wind texture for blizzards.
    • sampler2D _NoiseTex - palettes/noise.png, A smooth perlin noise texture, 64x64
    • sampler2D _NoiseTex2 - palettes/noise2.png, A pseudorandom RGB noise texture (each component is a random value), 256x256
    • sampler2D _CloudsTex - illustrations/cloudstexture.png
    • sampler2D _TextGradientTex - palettes/textgradient.png
    • sampler2D _ApartmentsTex - illustrations/apartments.png
    • sampler2D _CityPalette - palettes/citypalette.png
    • sampler2D _UniNoise - illustrations/uninoise.png, appears to be _NoiseTex2 but slightly darker. Perhaps normalized.
    • sampler2D _EnergySwirl - illustrations/energyring.png
    • sampler2D _pAngle - illustrations/pangle.png

Loading shaders into Futile

See FShader::CreateShader, which accepts a Unity Shader asset. As you might expect, you can export it via an asset bundle and then load it during runtime. Tutorials for how to do this exist elsewhere and are out of the scope of this post.

The only reason this section is here is to remind you: DO NOT load your shaders in your BepInEx Plugin's Awake! You will hard-crash Unity on startup when calling LoadAsset. Hook into RainWorld.LoadResources and load your asset bundle there instead.

In general, you should have some sort of loader class that statically stores your shaders. Do NOT call CreateShader repeatedly, and do NOT set the shader every frame! This will cause lag and is completely the wrong way to do it. Create your objects once and reuse them.

An example "loader class" might be as follows:

namespace MyMod {
	internal class Assets {
		// This references your shader after the game loads assets. Use this to get your shader.
		internal static FShader MyCustomShader { get; private set; }

		internal static void Initialize() {
			// Call this from your mod's OnEnable/Awake
			On.RainWorld.LoadResources += (originalMethod, @this) => {
				AssetBundle myAssets = ...; // This is up to you to write. See https://stackoverflow.com/questions/3314140/how-to-read-embedded-resource-text-file - the same applies for loading literally any type of file, not just text.
				Shader myShader = myAssets.LoadAsset<Shader>("Assets/myshader.shader"); // This is the same path as the one in your Unity editor.
				MyCustomShader = FShader.CreateShader(myShader.name, myShader);
			};
		}
	}
}

Then, to use this shader, simply edit the shader property of any FSprite.

Sometimes, you might need to change your own shader's properties. To do this, you need to access the actual Unity material itself that backs the FSprite. This is thankfully very easy.

private FSprite _myApplicableSprite; // Set this in your InitiateSprites method.

// This object is a Unity instance that stores per-instance material properties. It is significantly faster than
// setting a Renderer's material property (such that it doesn't share its material and renders separately).
// Note that it is static! **DO NOT** create a new MaterialPropertyBlock every time Draw/Update/etc gets called!
// If you make a new one on demand like that, you can (and probably will) lag the ever living hell out of the game.
// MaterialPropertyBlocks are designed to be cached and shared, it's the entire purpose of their existence.
private static MaterialPropertyBlock _materialProps = new MaterialPropertyBlock();

override void Draw(float timeStacker) {
	Renderer renderer = _myApplicableSprite._renderLayer._meshRenderer;
	renderer.GetPropertyBlock(_materialProps); // This loads existing data into the property block, so that it matches what you expect it to be on this renderer.
	_props.SetFloat("_YourProperty", ...); // This sets the property for this draw call. There are other Set methods (SetColor, SetVector, SetTexture, ...)
	renderer.SetPropertyBlock(_materialProps); // This updates the data for rendering.
}

Lighting and Shadows

This section covers quirks with the lighting system.

Quirks and Limitations

  • Lights will cast a shadow on all objects that are not 100% transparent, regardless of shader blend mode. This means that translucent (partly transparent) objects and will still cast shadows if a light is above them. This can be avoided by putting the object on a layer higher or equal to that of the light, or by putting the light's layer below it. Both of these solutions are less than ideal.
    • In general, you will want to avoid translucent objects where lighting is also present, as it is quite a hassle to ensure things render in the right order.
    • Flat lights do not cast shadows and are not subject to this problem.
    • For the sake of completeness, it is worth it to mention that shadows are not computed traditionally in Rain World; the depth buffer has no bearing on shadows, and there is no such thing as a shadowcaster pass. If it is a pixel that is on the screen and beneath a light, it casts a shadow, no exceptions.
  • Lights will cast shadows on all objects present in layers beneath them, but not on the layer they are currently on or higher layers.
    • Changing a LightSource's layer will change where shadows are drawn, however this can have unexpected behaviors, and below Background this will break the renderer with some rather unpleasant flashing. Layers higher than water have not been tested by me.
    • Changing the sortZ of a LightSources sprite(s) will change when it draws, however this behavior is broken due to how lighting works (see below). iter_draworder_fail

In this scenario, the LightSource's sprites are on sortZ=9999, and the puppet on 0. As you can see, the shadow computes its depth onto the world behind the puppet, then the puppet gets drawn, then the shadow gets drawn as if it's not actually there. Despite being opaque, the puppet is not casting a proper shadow, and is receiving the shadows for the world behind it instead of for itself.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment