Render Shadow map

We will use a framebuffer to render the shadow map and use that shadow map to draw shadow in our scene.

[1]:
import ipywebgl
import numpy as np

Initialize ipywebgl

[2]:
w = ipywebgl.GLViewer()
w.clear_color(.8, .8, .8 ,1)
w.clear()
w.enable(depth_test=True)
w.execute_commands(execute_once=True)

Create framebuffer

A float texture set as DEPTH_COMPONENT32F (webgl2)

[3]:
depth_texture = w.create_texture()
w.bind_texture('TEXTURE_2D', depth_texture)
w.tex_image_2d('TEXTURE_2D', 0, 'DEPTH_COMPONENT32F', 512, 512, 0, 'DEPTH_COMPONENT', 'FLOAT', None)
w.tex_parameter('TEXTURE_2D', 'TEXTURE_MAG_FILTER', 'NEAREST')
w.tex_parameter('TEXTURE_2D', 'TEXTURE_MIN_FILTER', 'NEAREST')
w.tex_parameter('TEXTURE_2D', 'TEXTURE_WRAP_S', 'CLAMP_TO_EDGE')
w.tex_parameter('TEXTURE_2D', 'TEXTURE_WRAP_T', 'CLAMP_TO_EDGE')

depth_fb = w.create_framebuffer()
w.bind_framebuffer('FRAMEBUFFER', depth_fb)
w.framebuffer_texture_2d('FRAMEBUFFER', 'DEPTH_ATTACHMENT', 'TEXTURE_2D', depth_texture, 0)
w.bind_framebuffer('FRAMEBUFFER', None)

w.execute_commands(execute_once=True)

Programs

Create the two programs. * one that render the scenes and uses the shadowmap * one that just render the scene from the light point of view.

We force the attribute location for the in_vert and in_normal, so we can use both shaders with the same vao’s

[4]:
scene_prog = w.create_program_ext(
"""#version 300 es

//the ViewBlock that is automatically filled by ipywebgl
layout(std140) uniform ViewBlock
{
    mat4 u_cameraMatrix;          //the camera matrix in world space
    mat4 u_viewMatrix;            //the inverse of the camera matrix
    mat4 u_projectionMatrix;      //the projection matrix
    mat4 u_viewProjectionMatrix;  //the projection * view matrix
};

uniform mat4 u_lightProjection;
uniform mat4 u_world;
uniform vec3 u_lightDir;

in vec3 in_vert;
in vec3 in_normal;

out vec3 v_color;
out vec4 v_shadowcoord;

void main() {
    vec4 world = u_world * vec4(in_vert, 1.0);
    gl_Position = u_viewProjectionMatrix * world;
    v_shadowcoord = u_lightProjection * world;
    v_color = vec3(1,1,1) * dot(-u_lightDir, in_normal);
}
"""
,
"""#version 300 es
precision highp float;

uniform float u_bias;
uniform sampler2D u_shadowmap;

in vec3 v_color;
in vec4 v_shadowcoord;

out vec4 f_color;
void main() {
    vec3 shadow = v_shadowcoord.xyz / v_shadowcoord.w;
    float currentDepth = shadow.z + u_bias;

    bool inRange =
      shadow.x >= 0.0 &&
      shadow.x <= 1.0 &&
      shadow.y >= 0.0 &&
      shadow.y <= 1.0;

    float projectedDepth = texture(u_shadowmap, shadow.xy).r;
    float shadowLight = (inRange && projectedDepth < currentDepth) ? 0.0 : 1.0;

    f_color = vec4(v_color * shadowLight, 1);
}
""",
{
    'in_vert' : 0,
    'in_normal' : 1
})


shadow_prog = w.create_program_ext(
"""#version 300 es

uniform mat4 u_lightProjection;
uniform mat4 u_world;
in vec3 in_vert;

void main() {
    gl_Position = u_lightProjection * u_world * vec4(in_vert, 1.0);
}
"""
,
"""#version 300 es
precision highp float;
out vec4 f_color;
void main() {
    f_color = vec4(1, 0.1, 0.1, 1.0);
}
""",
{'in_vert' : 0})

We will also create a program to render the frustum in the scene. So we can see what is the projection matrix for the light.

[5]:
frustum_prog = w.create_program_ext(
"""#version 300 es

//the ViewBlock that is automatically filled by ipywebgl
layout(std140) uniform ViewBlock
{
    mat4 u_cameraMatrix;          //the camera matrix in world space
    mat4 u_viewMatrix;            //the inverse of the camera matrix
    mat4 u_projectionMatrix;      //the projection matrix
    mat4 u_viewProjectionMatrix;  //the projection * view matrix
};

uniform mat4 u_lightProjection;

in vec3 in_vert;
void main() {
    vec4 world = inverse(u_lightProjection) * vec4(in_vert, 1.0);
    world = world/world.w;
    gl_Position = u_viewProjectionMatrix * world;
}
"""
,
"""#version 300 es
precision highp float;
out vec4 f_color;
void main() {
    f_color = vec4(0,0,0, 1);
}
"""
)

Create the VAO

we create vao’s for some isoshpere and a ground plane.

[6]:
sphere_vbo = w.create_buffer_ext(
    src_data=np.array(
      [[ 0.  ,  0.  ,  1.  ,  0.  ,  0.  ,  1.  ],
       [-0.72, -0.53,  0.45, -0.72, -0.53,  0.45],
       [ 0.28, -0.85,  0.45,  0.28, -0.85,  0.45],
       [ 0.89,  0.  ,  0.45,  0.89,  0.  ,  0.45],
       [ 0.28,  0.85,  0.45,  0.28,  0.85,  0.45],
       [-0.72,  0.53,  0.45, -0.72,  0.53,  0.45],
       [-0.89, -0.  , -0.45, -0.89, -0.  , -0.45],
       [-0.28, -0.85, -0.45, -0.28, -0.85, -0.45],
       [ 0.72, -0.53, -0.45,  0.72, -0.53, -0.45],
       [ 0.72,  0.53, -0.45,  0.72,  0.53, -0.45],
       [-0.28,  0.85, -0.45, -0.28,  0.85, -0.45],
       [-0.  ,  0.  , -1.  , -0.  ,  0.  , -1.  ],
       [ 0.16, -0.5 ,  0.85,  0.16, -0.5 ,  0.85],
       [-0.43, -0.31,  0.85, -0.43, -0.31,  0.85],
       [-0.26, -0.81,  0.53, -0.26, -0.81,  0.53],
       [ 0.53, -0.  ,  0.85,  0.53, -0.  ,  0.85],
       [ 0.69, -0.5 ,  0.53,  0.69, -0.5 ,  0.53],
       [ 0.16,  0.5 ,  0.85,  0.16,  0.5 ,  0.85],
       [ 0.69,  0.5 ,  0.53,  0.69,  0.5 ,  0.53],
       [-0.43,  0.31,  0.85, -0.43,  0.31,  0.85],
       [-0.26,  0.81,  0.53, -0.26,  0.81,  0.53],
       [-0.85, -0.  ,  0.53, -0.85, -0.  ,  0.53],
       [-0.59, -0.81, -0.  , -0.59, -0.81, -0.  ],
       [-0.  , -1.  , -0.  , -0.  , -1.  , -0.  ],
       [ 0.59, -0.81,  0.  ,  0.59, -0.81,  0.  ],
       [ 0.95, -0.31, -0.  ,  0.95, -0.31, -0.  ],
       [ 0.95,  0.31, -0.  ,  0.95,  0.31, -0.  ],
       [ 0.59,  0.81, -0.  ,  0.59,  0.81, -0.  ],
       [-0.  ,  1.  , -0.  , -0.  ,  1.  , -0.  ],
       [-0.59,  0.81, -0.  , -0.59,  0.81, -0.  ],
       [-0.95,  0.31,  0.  , -0.95,  0.31,  0.  ],
       [-0.95, -0.31,  0.  , -0.95, -0.31,  0.  ],
       [-0.69, -0.5 , -0.53, -0.69, -0.5 , -0.53],
       [ 0.26, -0.81, -0.53,  0.26, -0.81, -0.53],
       [ 0.85,  0.  , -0.53,  0.85,  0.  , -0.53],
       [ 0.26,  0.81, -0.53,  0.26,  0.81, -0.53],
       [-0.69,  0.5 , -0.53, -0.69,  0.5 , -0.53],
       [-0.53, -0.  , -0.85, -0.53, -0.  , -0.85],
       [-0.16, -0.5 , -0.85, -0.16, -0.5 , -0.85],
       [ 0.43, -0.31, -0.85,  0.43, -0.31, -0.85],
       [ 0.43,  0.31, -0.85,  0.43,  0.31, -0.85],
       [-0.16,  0.5 , -0.85, -0.16,  0.5 , -0.85]], dtype=np.float32).flatten()
)
indices = np.array(
    [[0, 13, 12], [12, 14, 2], [12, 13, 14], [13, 1, 14], [0, 12, 15], [15, 16, 3], [15, 12, 16], [12, 2, 16],
          [0, 15, 17], [17, 18, 4], [17, 15, 18], [15, 3, 18], [0, 17, 19], [19, 20, 5], [19, 17, 20], [17, 4, 20],
          [0, 19, 13], [13, 21, 1], [13, 19, 21], [19, 5, 21], [1, 22, 14], [14, 23, 2], [14, 22, 23], [22, 7, 23],
          [2, 24, 16], [16, 25, 3], [16, 24, 25], [24, 8, 25], [3, 26, 18], [18, 27, 4], [18, 26, 27], [26, 9, 27],
          [4, 28, 20], [20, 29, 5], [20, 28, 29], [28, 10, 29], [5, 30, 21], [21, 31, 1], [21, 30, 31], [30, 6, 31],
          [1, 31, 22], [22, 32, 7], [22, 31, 32], [31, 6, 32], [2, 23, 24], [24, 33, 8], [24, 23, 33], [23, 7, 33],
          [3, 25, 26], [26, 34, 9], [26, 25, 34], [25, 8, 34], [4, 27, 28], [28, 35, 10], [28, 27, 35], [27, 9, 35],
          [5, 29, 30], [30, 36, 6], [30, 29, 36], [29, 10, 36], [6, 37, 32], [32, 38, 7], [32, 37, 38], [37, 11, 38],
          [7, 38, 33], [33, 39, 8], [33, 38, 39], [38, 11, 39], [8, 39, 34], [34, 40, 9], [34, 39, 40], [39, 11, 40],
          [9, 40, 35], [35, 41, 10], [35, 40, 41], [40, 11, 41], [10, 41, 36], [36, 37, 6], [36, 41, 37], [41, 11, 37]],
        dtype=np.uint8).flatten()

# because we forced the index of the attribute we can pass in the number directly (instead of the shader and the name)
sphere_vao = w.create_vertex_array_ext(
    None,
    [
        (sphere_vbo, '3f32 3f32', 0, 1),
    ],
    indices
)

plane_vbo = w.create_buffer_ext(
    src_data=np.array(
      [[10  ,  0.  ,  -10.  ,  0.  ,  1  ,  0.  ],
       [-10  ,  0.  ,  -10.  ,  0.  ,  1  ,  0.  ],
       [-10  ,  0.  ,  10.  ,  0.  ,  1  ,  0.  ],
       [-10  ,  0.  ,  10.  ,  0.  ,  1  ,  0.  ],
       [10  ,  0.  ,  10.  ,  0.  ,  1  ,  0.  ],
       [10  ,  0.  ,  -10.  ,  0.  ,  1  ,  0.  ]], dtype=np.float32).flatten()
)

plane_vao = w.create_vertex_array_ext(
    None,
    [
        (plane_vbo, '3f32 3f32', 0, 1),
    ]
)

And we also create a vao for the frustum display. This is just a cube from -1 to 1 in clip space ( the shader will convert it back into world space )

[7]:
frustum_vbo = w.create_buffer_ext(
    src_data=np.array(
      [-1, -1, -1,
       1, -1, -1,
      -1,  1, -1,
       1,  1, -1,
      -1, -1,  1,
       1, -1,  1,
      -1,  1,  1,
       1,  1,  1,], dtype=np.float32)
)
frustum_indices = np.array(
    [
      0, 1,
      1, 3,
      3, 2,
      2, 0,

      4, 5,
      5, 7,
      7, 6,
      6, 4,

      0, 4,
      1, 5,
      3, 7,
      2, 6,
    ], dtype=np.uint8).flatten()

frustum_vao = w.create_vertex_array_ext(
    frustum_prog,
    [
        (frustum_vbo, '3f32', 'in_vert'),
    ],
    frustum_indices
)

Matrices

helper functions to create an orthographic and perspective projection matrix. (some function used in javascript to create the ViewProjection uniform )

[8]:
def ortho(width, height, near, far):
    A = 1. / width
    B = 1. / height
    C = -(far + near) / (far - near)
    D = -2. / (far - near)
    return np.array([
        [A, 0, 0, 0],
        [0, B, 0, 0],
        [0, 0, D, C],
        [0, 0, 0, 1]
    ], dtype=np.float32)


def projection(fov_y, aspect_ratio, near, far):
    ymax = near * np.tan(fov_y * np.pi / 360.0)
    xmax = ymax * aspect_ratio

    def frustum(left, right, bottom, top, near, far):
        A = (right + left) / (right - left)
        B = (top + bottom) / (top - bottom)
        C = -(far + near) / (far - near)
        D = -2. * far * near / (far - near)
        E = 2. * near / (right - left)
        F = 2. * near / (top - bottom)

        return np.array(
            [
                [E, 0, A, 0],
                [0, F, B, 0],
                [0, 0, C, D],
                [0, 0, -1, 0]
            ], dtype=np.float32)

    return frustum(-xmax, xmax, -ymax, ymax, near, far)

Scene

Create the scene. * first we create the light matrix * then we multiply it with the projection matrices * this is send to the shader to render the shadow map * then we multiply the projection matrices by the bias matrix to remap the [0,1] space into a [-1,1] space * and we send those matrices to the shader to render the scene with shadows.

[9]:
# light matrix on top looking down
light_matrix = np.eye(4, dtype=np.float32)
light_matrix[:3, 3] = np.array([0,20,20])
light_matrix[:3, 1] = np.array([0,0.707,-0.707])
light_matrix[:3, 2] = np.array([0,0.707,0.707])
inverse_light_dir = light_matrix[:3, 2] * -1
light_matrix = np.linalg.inv(light_matrix)

bias = np.array(
    [[0.5, 0.0, 0.0, 0.5],
    [0.0, 0.5, 0.0, 0.5],
    [0.0, 0.0, 0.5, 0.5],
    [0.0, 0.0, 0.0, 1.0]], dtype=np.float32)

# orthographic shadow
light_ortho = ortho(8,8, 10.0, 40.0)
light_ortho_projection = np.dot(light_ortho, light_matrix)
light_ortho_reprojection = np.dot(bias, light_ortho_projection)

# perspective shadow
light_persp = projection(40.0, 1.0, 10.0, 40.0)
light_persp_projection = np.dot(light_persp, light_matrix)
light_persp_reprojection = np.dot(bias, light_persp_projection)

# scene to render
spheres_count = 20
spheres = np.eye(4)[np.newaxis,...].repeat(spheres_count, axis=0)
spheres[:,:3,3] = np.random.random([spheres_count,3]) * 6 - 2.5
spheres[:,1,3] += 3
plane = np.eye(4, dtype=np.float32)

# draw the scene
def _draw_scene():
    w.bind_vertex_array(sphere_vao)
    for i in range(spheres.shape[0]):
        w.uniform_matrix('u_world', spheres[i,:,:].T)
        w.draw_elements('TRIANGLES', indices.shape[0], 'UNSIGNED_BYTE', 0)

    w.bind_vertex_array(plane_vao)
    w.uniform_matrix('u_world', plane.T)
    w.draw_arrays('TRIANGLES', 0, 6)


def _render_function(persp=False):

    w.enable(depth_test=True)
    # draw the shadow map
    w.bind_framebuffer('FRAMEBUFFER', depth_fb)
    w.viewport(0,0,512,512)
    w.clear()
    w.use_program(shadow_prog)
    if persp:
        w.uniform_matrix('u_lightProjection', light_persp_projection.T)
    else:
        w.uniform_matrix('u_lightProjection', light_ortho_projection.T)
    _draw_scene()

    # draw the final render
    w.bind_framebuffer('FRAMEBUFFER', None)
    w.viewport(0,0,w.width,w.height)
    w.clear()

    w.use_program(scene_prog)
    w.active_texture(0)
    w.bind_texture('TEXTURE_2D', depth_texture)
    if persp:
        w.uniform_matrix('u_lightProjection', light_persp_reprojection.T)
    else:
        w.uniform_matrix('u_lightProjection', light_ortho_reprojection.T)
    w.uniform('u_lightDir', inverse_light_dir)
    w.uniform('u_bias', np.array([-0.02], dtype=np.float32))
    _draw_scene()

    # draw the frustum
    w.disable(depth_test=True)
    w.use_program(frustum_prog)
    if persp:
        w.uniform_matrix('u_lightProjection', light_persp_projection.T)
    else:
        w.uniform_matrix('u_lightProjection', light_ortho_projection.T)
    w.bind_vertex_array(frustum_vao)
    w.draw_elements('LINES', frustum_indices.shape[0], 'UNSIGNED_BYTE', 0)

    # render in loop if needed
    w.execute_commands()

Display

display with the choice of shadow map projection

[11]:
from ipywidgets import widgets, interact


interact(
    _render_function,
    persp=widgets.Checkbox(description='perspective projection', value=False),
)

w.camera_pos = [33.45616955516928, 37.90264690920414, 36.766131171063385]
w.camera_pitch = -37
w.camera_yaw = 40
w
[11]: