I am a beginner at game dev and this task is way above my current capabilities. Even then I am motivated to make a good water shader and I need help with its reflections. I’ve managed to get running an efficient planar reflection script, with the caveat that it only works on a mesh (non-displaced, like a mirror). Using this on a displacing water shader won’t work as the texture generated from the script is placed on the displaced surface through the shader graph, without taking into account vector displacement.
In here I’ve made a simple sine wave (intended to be replaced), designed to test if the texture could distort across the distorting surface accurately.
Converting projection matrix in a shader

Because the near plane in the script isn’t updating with the displaced mesh, It fails at reflecting accurately. A more accurate look would be:

If it means much this is the code for reflection camera and texture, with the near clip plane calculations being done in here:
//////////////////////////////////////////////////////////////////////////////
// //
// Planar Reflections in Unity Jeet Ski project //
// //
// Universal RP Port made by Marcell Hermanowski, //
// PlanarReflections from Boat Attack, //
// PlanarReflectionsProbe from Rafael Bordoni, //
// PlanarReflections from the Youtuber I forgot sry //
// //
// Date: February 27, 2025 //
// //
//////////////////////////////////////////////////////////////////////////////
//Mess around with _probeSkybox. Cuz idk if I acc need it.
using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
[ExecuteAlways, AddComponentMenu("Rendering/Planar Reflections Probe")]
public class Plan2arReflectionsProbe : MonoBehaviour
{
[Serializable]
public class PlanarReflectionSettings
{
public ResolutionMultiplier m_ResolutionMultiplier = ResolutionMultiplier.ThreeQuarters;
public bool m_Shadows = false;
public bool m_Occlude = true;
}
public PlanarReflectionSettings m_settings = new PlanarReflectionSettings();
public bool dynamicReflactionSurface = false;
[Space(10)]
public float farClipPlane = 1000;
public bool renderBackground = true;
[Space(10)]
public bool renderInEditor = false;
[Serializable]
public class Additions
{
public RenderTexture m_ReflectionRenderTexture;
public GameObject _probeGO;
public Camera _probe;
}
public Additions m_additions = new Additions();
private Skybox _probeSkybox;
private UniversalAdditionalCameraData _probeData;
private void OnEnable()
{
FinalizeProbe();
RenderPipelineManager.beginCameraRendering += PreRender;
}
private void OnDisable()
{
FinalizeProbe();
}
private void OnDestroy()
{
FinalizeProbe();
}
private void InitializeProbe()
{
//Use m_additions._probeGO as an object (empty bucket) to hold a name, cam and Skybox.
m_additions._probeGO = new GameObject("", typeof(Camera), typeof(Skybox), typeof(UniversalAdditionalCameraData));
//Fill in that name with "PRCamera" + the id required for Unity to identify object with cam component.
m_additions._probeGO.name = "PRCamera" + m_additions._probeGO.GetInstanceID().ToString();
//Hide in Hierarchy, Scene, and "not unloaded by Resources.unloadUnusedAssets" (Whatever that means). Comment out for final draft.
//m_additions._probeGO.hideFlags = HideFlags.HideAndDontSave;
//Take the cam component of m_additions._probeGO and assign it cam object m_additions._probe. Any changes to cam would be done to m_additions._probe.
m_additions._probe = m_additions._probeGO.GetComponent<Camera>();
//idk. Maybe it's to not render on "Update", and that way I can render it when I want.
m_additions._probe.enabled = false;
//Same thing as above for Skybox.
_probeSkybox = m_additions._probeGO.GetComponent<Skybox>();
//idk bro.
_probeSkybox.enabled = false;
_probeSkybox.material = null;
//Grab "UniversalAdditionalCameraData" and input it into "_probeData" to allow for more camera mods.
_probeData = m_additions._probeGO.GetComponent<UniversalAdditionalCameraData>();
_probeData.requiresColorOption = CameraOverrideOption.Off;
_probeData.requiresDepthOption = CameraOverrideOption.Off;
}
private void FinalizeProbe()
{
RenderPipelineManager.beginCameraRendering -= PreRender;
if (Application.isEditor)
{
DestroyImmediate(m_additions._probeGO);
}
else
{
Destroy(m_additions._probeGO);
}
//idk. Releases gpu resources or sm.
m_additions.m_ReflectionRenderTexture.Release();
}
private bool CheckCamera(Camera cam)
{
//if camera is used for reflection probes.
//likely unnecessary.
if (cam.cameraType == CameraType.Reflection)
{
return true;
}
//if renderInEditor is false, and the camera is the one rendering in the editor
else if (!renderInEditor && cam.cameraType == CameraType.SceneView)
{
return true;
}
return false;
}
private void PreRender(ScriptableRenderContext context, Camera cam)
{
if (CheckCamera(cam))
{
return;
}
else if (m_additions._probe == null)
{
InitializeProbe();
}
Vector3 normal = GetNormal();
UpdateProbeSettings(cam);
CreateRenderTexture(cam);
UpdateProbeTransform(cam, normal);
CalculateObliqueProjection(normal);
var data = new PlanarReflectionSettingData(); // save quality settings and lower them for the planar reflections
data.Set(); // set quality settings
UniversalRenderPipeline.RenderSingleCamera(context, m_additions._probe);
data.Restore(); // restore the quality settings
}
private void UpdateProbeSettings(Camera cam)
{
//Equate the settings on the probe cam to the ones on the main. (What settings?)
m_additions._probe.CopyFrom(cam);
//I don't think I want to disable Occlusion Culling unless if it becomes problematic
m_additions._probe.useOcclusionCulling = m_settings.m_Occlude; //true to render, false to occlude
//Set the probe cam to the one used for reflection probes? I would have guessed that this is just to put it in it's own class.
m_additions._probe.cameraType = CameraType.Reflection;
//Set to avoid computing probe cams FOV and frustum.
//Idk why it's like this. I would have thought that calculating the frustum is important.
m_additions._probe.usePhysicalProperties = false;
// turn off shadows for the reflection camera. Idk if its working
_probeData.renderShadows = m_settings.m_Shadows; //true for shadows, false to hide shadows
//Set the probe cams far clip distance.
m_additions._probe.farClipPlane = farClipPlane;
//idk
_probeSkybox.material = null;
_probeSkybox.enabled = false;
if (renderBackground)
{
m_additions._probe.clearFlags = cam.clearFlags;
if (cam.GetComponent<Skybox>())
{
Skybox camSkybox = cam.GetComponent<Skybox>();
_probeSkybox.material = camSkybox.material;
_probeSkybox.enabled = camSkybox.enabled;
}
}
else
{
m_additions._probe.clearFlags = CameraClearFlags.Nothing;
}
}
private void CreateRenderTexture(Camera cam)
{
int width = Mathf.RoundToInt(cam.pixelWidth) * GetScaleValue() / 100;
int height = cam.pixelHeight * GetScaleValue() / 100;
if (m_additions.m_ReflectionRenderTexture == null || m_additions.m_ReflectionRenderTexture.width != width || m_additions.m_ReflectionRenderTexture.height != height)
{
m_additions.m_ReflectionRenderTexture.Release();
m_additions.m_ReflectionRenderTexture.width = width;
m_additions.m_ReflectionRenderTexture.height = height;
m_additions.m_ReflectionRenderTexture.depth = 16;
//const bool useHdr10 = true;
}
//Am I supposed to ReflectionRenderTexture.Release(); for every frame because it lets go of the resources (memory) holding the texture(s)?
m_additions._probe.targetTexture = m_additions.m_ReflectionRenderTexture;
}
//The general dir the surface is pointing
private Vector3 GetNormal()
{
return transform.up;
//If you wanna do calculate this more manually:
//Mathf.Sin(customNormal2.transform.eulerAngles.z * Mathf.Deg2Rad) for x
//customNormal2.transform.up.y for y
//Mathf.Sin(customNormal2.transform.eulerAngles.x * Mathf.Deg2Rad for z
}
// The probe's camera position should be the the current camera's position
// mirrored by the reflecting plane. Its rotation mirrored too.
private void UpdateProbeTransform(Camera cam, Vector3 normal)
{
if (dynamicReflactionSurface)
{
//Get the distance between th main cam pos [16] to object pos [3] [,= 13],
Vector3 proj = normal * Vector3.Dot(normal, cam.transform.position - transform.position);
//Get the main cams xyz pos and subtracts 2 * proj
m_additions._probe.transform.position = cam.transform.position - 2 * proj;
}
else
{
float proj1 = transform.position.y + (transform.position.y - cam.transform.position.y);
m_additions._probe.transform.position = new Vector3(cam.transform.position.x, proj1, cam.transform.position.z);
}
//This here is to test if my equivalent code from above would run in different cases assuming the reflective surface is flat (hopefully while being more efficient):
//float proj1 = transform.position.y + (transform.position.y - cam.transform.position.y);
//m_additions._probe.transform.position = new Vector3(cam.transform.position.x, proj1, cam.transform.position.z);
//Non-functional. If mainCamHeight is less than the waterHeight then delete the probe and the texture.
//Although this should only be done way earlier so a re-layout of the program will have to be done.
//Functional. If waterHeight world pos is positive [3], mainCamHeight is positive [16], then probe height is [10] and relative difference is 13 for both:
//m_additions._probe.transform.position.y = (waterHeight [World pos: 3] + (waterHeight [World pos: 3] - mainCamHeight [World pos: 16]) [= -13]) [= World pos: -10]
//Functional. If waterHeight world pos is negative [-3], mainCamHeight is positive [16], then probe height is [21] and relative difference is 19 for both:
//m_additions._probe.transform.position.y = (waterHeight [World pos: -3] + (waterHeight [World pos: -3] - mainCamHeight [World pos: 16]) [= -19]) [= World pos: -21]
//Functional. If waterHeight world pos is negative [3], mainCamHeight is negative [-1], then probe height is [-5] and relative difference is 2 for both:
//m_additions._probe.transform.position.y = (waterHeight [World pos: -3] + (waterHeight [World pos: -3] - mainCamHeight [World pos: -1]) [= -2]) [= World pos: -5]
// <- <- Main [16] World pos
// <- <- [13] Relative difference
// --------------- Water [3] World pos
// <- [13] Relative difference
// <- Probe [-10] World pos
//Reflect() is a Unity function that will recalculate orientation based on the direction the main cam's pointing and surface normal, which we'll store.
//To rotate the camera around the up axis (Unity z, Blender y), parallel to the surface.
Vector3 probeForward = Vector3.Reflect(cam.transform.forward, normal);
//To rotate the cam towards up or down.
Vector3 probeUp = Vector3.Reflect(cam.transform.up, normal);
m_additions._probe.transform.LookAt(m_additions._probe.transform.position + probeForward, probeUp);
}
// The clip plane should coincide with the plane with reflections.
private void CalculateObliqueProjection(Vector3 normal)
{
Matrix4x4 viewMatrix = m_additions._probe.worldToCameraMatrix;
Vector3 viewPosition = viewMatrix.MultiplyPoint(transform.position);
Vector3 viewNormal = viewMatrix.MultiplyVector(normal);
Vector4 plane = new Vector4(viewNormal.x, viewNormal.y, viewNormal.z, -Vector3.Dot(viewPosition, viewNormal));
m_additions._probe.projectionMatrix = m_additions._probe.CalculateObliqueMatrix(plane);
}
public enum ResolutionMultiplier
{
Full,
ThreeQuarters,
Half,
Quarter,
PotatoPcTypeStuff
}
private int GetScaleValue()
{
switch (m_settings.m_ResolutionMultiplier)
{
case ResolutionMultiplier.Full:
return 100;
case ResolutionMultiplier.ThreeQuarters:
return 75;
case ResolutionMultiplier.Half:
return 50;
case ResolutionMultiplier.Quarter:
return 25;
case ResolutionMultiplier.PotatoPcTypeStuff:
return 10;
default:
return 75;
}
}
class PlanarReflectionSettingData
{
private readonly bool _fog;
private readonly int _maxLod;
private readonly float _lodBias;
public PlanarReflectionSettingData()
{
_fog = RenderSettings.fog;
_maxLod = QualitySettings.maximumLODLevel;
_lodBias = QualitySettings.lodBias;
}
public void Set()
{
//GL.invertCulling = true;
RenderSettings.fog = false; // disable fog for now as it's incorrect with projection
QualitySettings.maximumLODLevel = 1;
QualitySettings.lodBias = _lodBias * 0.5f;
}
public void Restore()
{
//GL.invertCulling = false;
RenderSettings.fog = _fog;
QualitySettings.maximumLODLevel = _maxLod;
QualitySettings.lodBias = _lodBias;
}
}
}
I wasn’t able to understand the matrices, that feels way beyond my skill set right now but considering this Unity forum it may be a required:
