Shader Aspect Ratio Question

Hello! I am trying to adapt a shader I wrote for OpenToonz to work in Defold. It works as intended in OpenToonz. Eventually I would like to turn it into a dynamic lighting system for painted normal mapped sprites. I have seen this topic in many posts around these forums and found quite a few lighting examples, all of which have been helpful but do not do exactly what I am looking for.

After a lot of failure and trying to understand Defold’s rendering pipeline, materials, etc… I have something more or less working. My final problem for this phase of things is that when I resize the window, my ‘light position’ goes super awry if the aspect ratio changes. Honestly, a lot of the math involved in this is way over my head and I suspect it is something in the way I’m converting the Defold coordinates to the shader variables. Hoping someone can take a look and spot something that may be off?

Hopefully I’m not forgetting anything here but I had to do quite a bit to get to this point. I’ll try to show it all here. Also feel free to point out anything that may bite me in the future or could be done better! :slight_smile:

So first, the ‘sprites’ are actually game objects. This is the start of how I’m getting around Defold’s limitation of a single ‘image’ per sprite. In this game object is a sprite named ‘albedo’ and a sprite named ‘normal’. The albedo sprite uses the standard sprite material (for now.) The normal sprite uses a copy of the material but with tag ‘normal’ to split rendering. Pretty straight forward. Here’s the outline and an example of a colored octagon image and its normal map, just for testing.

image

Now, the render script. I copied it from builtins and renamed it to core.render_script. For completeness here is the full render script.

--
-- projection that centers content with maintained aspect ratio and optional zoom
--
local function fixed_projection(near, far, zoom)
    local projected_width = render.get_window_width() / (zoom or 1)
    local projected_height = render.get_window_height() / (zoom or 1)
    local xoffset = -(projected_width - render.get_width()) / 2
    local yoffset = -(projected_height - render.get_height()) / 2
    return vmath.matrix4_orthographic(xoffset, xoffset + projected_width, yoffset, yoffset + projected_height, near, far)
end
--
-- projection that centers and fits content with maintained aspect ratio
--
local function fixed_fit_projection(near, far)
    local width = render.get_width()
    local height = render.get_height()
    local window_width = render.get_window_width()
    local window_height = render.get_window_height()
    local zoom = math.min(window_width / width, window_height / height)
    return fixed_projection(near, far, zoom)
end
--
-- projection that stretches content
--
local function stretch_projection(near, far)
    return vmath.matrix4_orthographic(0, render.get_width(), 0, render.get_height(), near, far)
end

local function get_projection(self)
    return self.projection_fn(self.near, self.far, self.zoom)
end

local function window_resized(self, width, height)
  render.set_render_target_size(self.albedo_target, width, height)
  render.set_render_target_size(self.normal_target, width, height)
end

function init(self)
    self.tile_pred = render.predicate({"tile"})
    self.normal_pred = render.predicate({"normal"})
    self.gui_pred = render.predicate({"gui"})
    self.text_pred = render.predicate({"text"})
    self.particle_pred = render.predicate({"particle"})
    self.composite_pred = render.predicate({"composite"})

    self.clear_color = vmath.vector4(0, 0, 0, 0)
    self.clear_color.x = sys.get_config_number("render.clear_color_red", 0)
    self.clear_color.y = sys.get_config_number("render.clear_color_green", 0)
    self.clear_color.z = sys.get_config_number("render.clear_color_blue", 0)
    self.clear_color.w = sys.get_config_number("render.clear_color_alpha", 0)

    self.view = vmath.matrix4()

    -- default is stretch projection. copy from builtins and change for different projection
    -- or send a message to the render script to change projection:
    -- msg.post("@render:", "use_stretch_projection", { near = -1, far = 1 })
    -- msg.post("@render:", "use_fixed_projection", { near = -1, far = 1, zoom = 2 })
    -- msg.post("@render:", "use_fixed_fit_projection", { near = -1, far = 1 })
    self.near = -1
    self.far = 1
    self.projection_fn = fixed_projection

    local color_params = { format = render.FORMAT_RGBA, width = render.get_window_width(), height = render.get_window_height() }
    local target_params = {[render.BUFFER_COLOR_BIT] = color_params }
    self.albedo_target = render.render_target("albedo", target_params)
    self.normal_target = render.render_target("normal", target_params)
end

local function clear(self)
    render.set_depth_mask(true)
    render.set_stencil_mask(0xff)
    render.clear({[render.BUFFER_COLOR_BIT] = self.clear_color, [render.BUFFER_DEPTH_BIT] = 1, [render.BUFFER_STENCIL_BIT] = 0})

    render.set_viewport(0, 0, render.get_window_width(), render.get_window_height())
    render.set_view(self.view)
    render.set_projection(get_projection(self))

    render.set_depth_mask(false)
    render.disable_state(render.STATE_DEPTH_TEST)
    render.disable_state(render.STATE_STENCIL_TEST)
    render.enable_state(render.STATE_BLEND)
    render.set_blend_func(render.BLEND_SRC_ALPHA, render.BLEND_ONE_MINUS_SRC_ALPHA)
    render.disable_state(render.STATE_CULL_FACE)
end

function update(self)
    local window_width = render.get_window_width()
    local window_height = render.get_window_height()
    if window_width == 0 or window_height == 0 then return end

    local proj = get_projection(self)
    local frustum = proj * self.view

    -- render original scene to albedo target
    render.set_render_target(self.albedo_target)
    clear(self)
    render.draw(self.tile_pred, {frustum = frustum})
    render.draw(self.particle_pred, {frustum = frustum})
    render.draw_debug3d()

    -- render normals to normal target
    render.set_render_target(self.normal_target)
    clear(self)
    render.draw(self.normal_pred, {frustum = frustum})

    -- render composite to screen
    render.set_render_target(render.RENDER_TARGET_DEFAULT)
    clear(self)
    render.set_view(self.view)
    render.set_projection(self.view)

    render.enable_texture(0, self.albedo_target, render.BUFFER_COLOR_BIT)
    render.enable_texture(1, self.normal_target, render.BUFFER_COLOR_BIT)
    render.draw(self.composite_pred, {frustum = frustum})
    render.disable_texture(0)
    render.disable_texture(1)

    
    -- render GUI
    local view_gui = vmath.matrix4()
    local proj_gui = vmath.matrix4_orthographic(0, window_width, 0, window_height, -1, 1)
    local frustum_gui = proj_gui * view_gui

    render.set_view(view_gui)
    render.set_projection(proj_gui)

    render.enable_state(render.STATE_STENCIL_TEST)
    render.draw(self.gui_pred, {frustum = frustum_gui})
    render.draw(self.text_pred, {frustum = frustum_gui})
    render.disable_state(render.STATE_STENCIL_TEST)
end

function on_message(self, message_id, message)
    if message_id == hash("clear_color") then
        self.clear_color = message.color
    elseif message_id == hash("set_view_projection") then
        self.view = message.view
        self.projection = message.projection
    elseif message_id == hash("use_camera_projection") then
        self.projection_fn = function() return self.projection or vmath.matrix4() end
    elseif message_id == hash("use_stretch_projection") then
        self.near = message.near or -1
        self.far = message.far or 1
        self.projection_fn = stretch_projection
    elseif message_id == hash("use_fixed_projection") then
        self.near = message.near or -1
        self.far = message.far or 1
        self.zoom = message.zoom or 1
        self.projection_fn = fixed_projection
    elseif message_id == hash("use_fixed_fit_projection") then
        self.near = message.near or -1
        self.far = message.far or 1
        self.projection_fn = fixed_fit_projection
    elseif message_id == hash("window_resized") then
        window_resized(self, message.width, message.height)
    end
end

The important bits in here are: there is an albedo and normal render target. And a normal and composite render predicate. I also set the projection to fixed_projection instead of stretched. There is also a window_resized function that will set the render target sizes to the new width/height (otherwise stuff just disappears and it is a mess :sweat_smile: ) Then there is a new entry in ‘on_message’ to receive a window resized message with the new width/height. This comes from a render-options game object script.

Overall, all this has been added to first render the scene to the albedo target. Then render the normal map sprites to the normal target. Then draw these to a 2x2 quad model using the composite shader and render predicate. So this brings us to the actual shader code.

First, for reference, here is the OpenToonz shader I wrote. OpenToonz provides a ‘worldToOutput’ variable. Which, as far as I understand, is mapping from the ‘world’ which is the canvas to the screen/output. It is a mat3. OpenToonz also requires ‘pre multiplication’ at the end.

#ifdef GL_ES
precision mediump float;
#endif

uniform mat3  worldToOutput;
uniform mat3  outputToWorld;

uniform sampler2D inputImage[2];
uniform mat3      outputToInput[2];

uniform vec2  light_position;
uniform float light_radius;
uniform float light_depth;
uniform vec4  light_color;

uniform vec4  ambient_color;

uniform vec2  resolution;

float det(mat3 m) { return m[0][0] * m[1][1] - m[0][1] * m[1][0]; }
float normaled(float v, float maxv, float minv) { return (v - minv) / (maxv - minv); }

void main() {
  vec2 uv            = gl_FragCoord.xy/resolution.xy;

  vec4 color         = texture2D(inputImage[0], uv);
  vec3 normal        = texture2D(inputImage[1], uv).rgb;


  vec2 light_out_pos = (worldToOutput * vec3(light_position, 1.0)).xy / resolution;
 float light_out_rad = light_radius * sqrt(abs(det(worldToOutput))) / resolution.y;

  vec2 light_pos     = light_out_pos - uv;
       light_pos.y  /= resolution.x/resolution.y;

 float falloff       = 1.0 / normaled(length(light_pos), light_out_rad * 0.5, 0.0);

  vec3 light_dir     = vec3(light_pos, light_depth);
  vec3 normal_map    = normalize(normal * 2.0 - 1.0); // * vec3(1.0, -1.0, 1.0); // Invert Y if needed?
 float n_dot_l       = max(dot(normal_map, normalize(light_dir)), 0.0);

  vec3 diffuse       = light_color.rgb * n_dot_l * falloff;
       diffuse       = 1.0 - exp( -diffuse );

  gl_FragColor       = vec4(color.rgb * (ambient_color.rgb + diffuse), color.a);
  gl_FragColor.rgb *= gl_FragColor.a; // Premultiplication (Required by OpenToonz)

Now for the conversion to Defold. The vertex program is pretty simple. I think I copied it from the builtin sprite vp. But I made a ‘worldToOutput’ variable to pass to the fragment shader as a mat3. This converts the view_proj to mat3, which I just totally guessed might work and it seems to for the most part! :laughing:

uniform highp mat4 view_proj;

// positions are in world space
attribute highp vec4 position;
attribute mediump vec2 texcoord0;

varying mediump vec2 var_texcoord0;
varying mediump mat3 worldToOutput;

mat3 to_mat3(mat4 m4) {
  return mat3(
      m4[0][0], m4[0][1], m4[0][2],
      m4[1][0], m4[1][1], m4[1][2],
      m4[2][0], m4[2][1], m4[2][2]);
}

void main()
{
    gl_Position = view_proj * vec4(position.xyz, 1.0);
    var_texcoord0 = texcoord0;
    worldToOutput = to_mat3(view_proj);
}

Then the fragment shader fp program looks more like the OpenToonz shader.

varying mediump vec2 var_texcoord0;
varying mediump mat3 worldToOutput;

uniform lowp sampler2D albedo;
uniform lowp sampler2D normal;

uniform lowp vec4 display_info;
uniform lowp vec4 mousepos;

float det(mat3 m) { return m[0][0] * m[1][1] - m[0][1] * m[1][0]; }
float normaled(float v, float maxv, float minv) { return (v - minv) / (maxv - minv); }

void main()
{
  vec3 light_color   = vec3(0.6, 0.6, 0.6); // TODO move
  vec3 ambient_color = vec3(0.0, 0.0, 0.0); // TODO move
  vec2 light_position = mousepos.xy;
 float light_radius   = 350.0;
 float light_depth    = 0.1;

  vec4 color         = texture2D(albedo, var_texcoord0);
  vec3 norm          = texture2D(normal, var_texcoord0).rgb;
  vec2 resolution    = display_info.xy;

  // TODO figure out how to get worldToOutput matrix
  vec2 light_out_pos = (worldToOutput * vec3(light_position, 1.0)).xy / resolution;
 float light_out_rad = light_radius * sqrt(abs(det(worldToOutput))) / resolution.y;

  vec2 light_pos     = light_out_pos - var_texcoord0;
       light_pos.y  /= resolution.x/resolution.y;

 float falloff       = 1.0 / normaled(length(light_pos), light_out_rad * 0.5, 0.0);

  vec3 light_dir     = vec3(light_pos, light_depth);
  vec3 normal_map    = normalize(norm * 2.0 - 1.0);
 float n_dot_l       = max(dot(normal_map, normalize(light_dir)), 0.0);

  vec3 diffuse       = light_color.rgb * n_dot_l * falloff;
       diffuse       = 1.0 - exp( -diffuse );

  gl_FragColor       = vec4(color.rgb * (ambient_color.rgb + diffuse), color.a);
//   gl_FragColor.rgb *= gl_FragColor.a; // Premultiplication (Required by OpenToonz) Guessing this is not needed for Defold.
}

There are a lot of arbitrary values in there that I plan to eventually pass in. For light position I am using the mouse position so I can easily move it around. For this I have a composite script attached to the composite game object along with the 2 x 2 model used to render the scene. This composite game object, along with the render-options game object and any ‘sprite’ game objects are added to the main collection.

So here is the composite script.

local aide = require "core.aide"

local function window_resize(self, width, height)
  go.set("#model", "display_info", vmath.vector4(width, height, 0.2, 1))
end

function init(self)
	self.width, self.height = window.get_size()
  aide.WINDOW_RESIZE_ACTIONS[go.get_id()] = {}
	go.set("#model", "display_info", vmath.vector4(self.width, self.height, 0.2, 1))
	msg.post(".", "acquire_input_focus")
end

function on_input(self, action_id, action)
	if action.x and action.y then
		go.set("#model", "mousepos", vmath.vector4(action.x, action.y, 1, 1))
	end
end

function on_message(self, message_id, message, sender)
  if message_id == aide.WINDOW_RESIZE_HASH then
    window_resize(self, message.width, message.height)
  end
end

And here is the render-options script.

local aide = require "core.aide"

local function window_resize(width, height)
  msg.post("@render:", "window_resized", { width = width, height = height })
end

function init(self)
  window.set_listener(aide.window_listener)
  aide.WINDOW_RESIZE_ACTIONS[go.get_id()] = {}
end

function on_message(self, message_id, message, sender)
  if message_id == aide.WINDOW_RESIZE_HASH then
    window_resize(message.width, message.height)
  end
end

And to accommodate multiple things needing to know when the window is resized, I made a lua module called aide. This is what the other 2 scripts are referring to. They basically add themselves to a list and when the windows resize event happens, the module posts a message to everything in that list with the new width/height parameters. In the composite script this sets the ‘resolution’ on the material for the model. And on the render-options script it lets the render script know the window size changed, so it can update the render targets’ size. The relevant part of ‘aide’ is here:

local M = {}

M.WINDOW_WIDTH = 1920.0 -- TODO add an init function here and get these from that
M.WINDOW_HEIGHT = 1080.0
M.WINDOW_RESIZE_HASH = hash("resize window")
M.WINDOW_RESIZE_ACTIONS = {}

function M.window_listener(self, event, data)
  if event == window.WINDOW_EVENT_RESIZED then
    M.WINDOW_WIDTH, M.WINDOW_HEIGHT = window.get_size()
    for k, v in pairs(M.WINDOW_RESIZE_ACTIONS) do
      v["width"] = M.WINDOW_WIDTH
      v["height"] = M.WINDOW_HEIGHT
      msg.post(k, M.WINDOW_RESIZE_HASH, v)
    end
  end
end

return M

SOOOOO… with all that in place. It works! I don’t think I’m forgetting anything… maybe it would have been easier to just upload a git repo. :thinking: Here is the results when it works and when it doesn’t.


The first image is correct! However, the second one is not. The difference is, I stretched the window much wider than normal so the aspect ratio was way different. The problem is, when I drag my mouse way off to the right, you can see the ‘light’ makes its way over to the octagon example.

Even though I update the resolution through the material on the composite script, it fails to properly translate the position of the mouse if the aspect ratio changes. At least I think that is the problem.

If you made it this far, thank you already! If you have any suggestions thank you x1000! :slight_smile:

Looks great!

I believe the problem is that you are applying something other than a stretch projection. It is understandable that you don’t want to stretch the graphics, but that also means that you need to translate from the mouse screen coordinates into the world coordinates matching the projection used in your render script. The math needed to do this can be found here: Camera component manual

Side-note: In the latest beta of Defold it is possible to query a camera (if you are using one) for its view and projection.

2 Likes

I played around with the projection some. Changing to stretch didn’t actually change anything with the light position (just stretched the content.) The light would still be about the same off position when I made the aspect ratio wider.

I spent a few hours trying to different calculations with the projection. Then stopped and took a simpler approach. In the display_info where I was passing the current resolution as the x, y parameters, I also pass the original render target resolution as the z, w parameters. Then I just updated the resolution to calculate the difference.

vec2 current_res    = d    isplay_info.xy;
vec2 adjust         = display_info.zw / display_info.xy;
vec2 resolution     = current_res * adjust;

This actually seems to have mostly worked. The light follows the mouse no matter what I scale the window to. :slight_smile: There was a slight difference in the light radius though but it is so small it could just be due to floating point variance after the calculation.

Now my problem is… when I do switch to a camera view and move it to somewhere other than 0, 0 everything just disappears! :frowning:

Sadly, I don’t even know what to do to diagnose this. I assume it is something to do with the projection again. This is where everything just kind of goes over my head.

Think I figured it out! Guessing since the camera acquiring focus changes the view/projection, it wasn’t working out with the way I had set those in the render script before drawing the composite predicate. So I changed those to both just be vmath.matrix4() and that seems to make the camera work again. Probably will need to do more testing… but at this point I think my first phase of attempting lighting through shader is working. :smiley:

Edit: After some testing, it is not quite working after all. The camera does work and I can kind of move it around some but once I go to any direction a little too far, everything disappears again… so back to the drawing board. :frowning:

Second Edit: Finally figured out it was the frustum setting on the composite predicate draw call. When I changed it to match the GUI’s frustum, the stuff no longer disappears! :tada:

1 Like