Believe it or not, I have written game code as a hobby for ~35 years and shaders have been around for the larger part of it, but I never wrote any shader code. I find rendering in its entirety rather boring and prefer to focus on game logic and storytelling, but as a solo developer targeting a standalone headset I knew I had to look into all things rendering – including shaders.
I wanted to have a health display for the player, and one common way in VR is to have it on a wristwatch attached to the player’s hand model. Unfortunately the choice of wristwatches in my purchased asset packs was limited and the few I had didn’t have the required visual fidelity to show them on the player’s wrist, since the hand models are more detailed than other game models.
So I started to search for free wristwatch models and found this “Keyboard Clock” 3D model with a permissive license:
Obviously I couldn’t use this right away but it gave me a good starting point. I fired up Blender, imported the model, and made the necessary changes. I removed all keys, simplified the materials, and changed the form slightly. Finally and most importantly, I separated the display from the rest of the model so that I could easily assign a render texture material in Unity.
As you can see, the wristband still has plenty of polygons and could be simplified further, but that was good enough for the moment. Instead of continuing to model in Blender, where my abilities are rather limited, I decided to attach some massively scaled down sci-fi greeble prefabs from my art packs on both sides of the watch.
I attached the watch to my XR rig’s left hand and started to configure a separate camera for UI rendering to a render texture, created the corresponding material, and assigned the material to the submesh of my watch.
Then I moved on to create one raw image inside of the canvas for the wristwatch display which was supposed to hold the ECG. And then I created a material and started writing a shader. It’s a simple fragment shader function which normalizes the elapsed game time and the vertex x-coordinate on a range [0,1) and moves the y-value like a sine wave for the first part of it, with configurable speed, amplitude, etc., while keeping the result “flat” for the remaining range.
I generated this shader code with the help of ChatGPT and while I understand most of the output, looking at it still feels alien to me. This is particularly because in a Unity shader, several things like plain GLSL and Unity shader compiler specific instructions are mixed, and Unity specific helper functions are used.
Here’s the full shader code in case you’re interested. I added some notes because not everything that ChatGPT generated seemed to make sense, which proves that any ChatGPT output should be verified and challenged. If you have substantial suggestions for improvement, feel free to share them, too.
Shader "Custom/ECGShader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_YScale ("Y Scale", Float) = 50.0
_Frequency ("Frequency", Float) = 1.0
_Speed ("Speed", Float) = 1.0
_StrokeWidth ("Stroke Width", Float) = 0.01
}
SubShader
{
Tags { "RenderType"="Transparent" "Queue"="Overlay" }
LOD 100
Pass
{
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata_t
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float _YScale;
float _Frequency;
float _Speed;
float _StrokeWidth;
v2f vert (appdata_t v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
float ECGFunction(float t)
{
// NB: I'm not sure why ChatGPT separated peak and dip rendering instead of simply using a full period... just one "if (t < 0.2)" would be sufficient
if (t < 0.1)
return sin(t * 3.14159 * 10.0); // Peak
if (t < 0.2)
return -sin((t - 0.1) * 3.14159 * 10.0); // Dip
// NB: Also not sure about this, it's nearly 0 for any "t"; "return 0" looks the same on screen
return exp(-30.0 * t); // Exponential decay for the rest
}
fixed4 frag (v2f i) : SV_Target
{
float t = (i.uv.x + _Time * _Speed) * _Frequency;
t = frac(t); // Keep t within [0,1)
float y = ECGFunction(t) * _YScale + 0.5;
float alpha = smoothstep(_StrokeWidth, 0.0, abs(i.uv.y - y));
return float4(0.0, 1.0, 0.0, alpha);
}
ENDCG
}
}
FallBack "Diffuse"
}
Currently this is just a gimmick, but I hope to change the heart frequency based on the player’s exhaustion later on.
Here’s how the watch looks ingame at the time of this writing:
The greeble has no specific function yet and I agree that the small antenna would be prone to break in real life, but luckily this is VR.
Leave a Reply