Normal Mapping

Screen shot of normal mapping
normal_mapping_simple.py
  1"""
  2Simple normal mapping example.
  3
  4We load a diffuse and normal map and render them using a simple shader.
  5The normal texture stores a direction vector in the RGB channels
  6pointing up from the surface.
  7
  8For simplicity we use the texture coordinates to determine the
  9screen position but this can be done in other ways.
 10
 11Controls:
 12    Mouse: Move light source
 13    Mouse wheel: Move light source in and out
 14
 15Run this example from the command line with:
 16python -m arcade.examples.gl.normal_mapping_simple
 17"""
 18
 19import arcade
 20from arcade.gl import geometry
 21
 22
 23class NormalMapping(arcade.Window):
 24    def __init__(self):
 25        super().__init__(512, 512, "Normal Mapping")
 26
 27        # Load the color (diffuse) and normal texture
 28        # These should ideally be the same size
 29        self.texture_diffuse = self.ctx.load_texture(
 30            ":resources:images/test_textures/normal_mapping/diffuse.jpg"
 31        )
 32        self.texture_normal = self.ctx.load_texture(
 33            ":resources:images/test_textures/normal_mapping/normal.jpg"
 34        )
 35
 36        # Shader program doing basic normal mapping
 37        self.program = self.ctx.program(
 38            vertex_shader="""
 39                #version 330
 40
 41                // Inputs from the quad_fs geometry
 42                in vec2 in_vert;
 43                in vec2 in_uv;
 44
 45                // Output to the fragment shader
 46                out vec2 uv;
 47
 48                void main() {
 49                    uv = in_uv;
 50                    gl_Position = vec4(in_vert, 0.0, 1.0);
 51                }
 52
 53            """,
 54            fragment_shader="""
 55                #version 330
 56
 57                // Samplers for reading from textures
 58                uniform sampler2D texture_diffuse;
 59                uniform sampler2D texture_normal;
 60                // Global light position we can set from python
 61                uniform vec3 light_pos;
 62
 63                // Input from vertex shader
 64                in vec2 uv;
 65
 66                // Output to the framebuffer
 67                out vec4 f_color;
 68
 69                void main() {
 70                    // Read RGBA color from the diffuse texture
 71                    vec4 diffuse = texture(texture_diffuse, uv);
 72                    // Read normal from RGB channels and convert to a direction vector.
 73                    // These vectors are like a needle per pixel pointing up from the surface.
 74                    // Since RGB is 0-1 we need to convert to -1 to 1.
 75                    vec3 normal = normalize(texture(texture_normal, uv).rgb * 2.0 - 1.0);
 76
 77                    // Calculate the light direction.
 78                    // This is the direction between the light position and the pixel position.
 79                    vec3 light_dir = normalize(light_pos - vec3(uv, 0.0));
 80
 81                    // Calculate the diffuse factor.
 82                    // This is the dot product between the light direction and the normal.
 83                    // It's basically calculating the angle between the two vectors.
 84                    // The result is a value between 0 and 1.
 85                    float diffuse_factor = max(dot(normal, light_dir), 0.0);
 86
 87                    // Write the final color to the framebuffer.
 88                    // We multiply the diffuse color with the diffuse factor.
 89                    f_color = vec4(diffuse.rgb * diffuse_factor, 1.0);
 90                }
 91            """,
 92        )
 93        # Configure what texture channel the samplers should read from
 94        self.program["texture_diffuse"] = 0
 95        self.program["texture_normal"] = 1
 96
 97        # Shortcut for a full screen quad
 98        # It has two buffers with positions and texture coordinates
 99        # named "in_vert" and "in_uv" so we need to use that in the vertex shader
100        self.quad_fs = geometry.quad_2d_fs()
101
102        # Keep track of mouse coordinates for light position
103        self.mouse_x = 0.0
104        self.mouse_y = 0.0
105        self.mouse_z = 0.25
106
107        self.text = arcade.Text("0, 0, 0", 20, 20, arcade.color.WHITE)
108
109    def on_draw(self):
110        self.clear()
111
112        # Bind the textures to the channels we configured in the shader
113        self.texture_diffuse.use(0)
114        self.texture_normal.use(1)
115
116        # Update the light position uniform variable
117        self.program["light_pos"] = self.mouse_x, self.mouse_y, self.mouse_z
118
119        # Run the normal mapping shader (fills a full screen quad)
120        self.quad_fs.render(self.program)
121
122        # Draw the mouse coordinates
123        self.text.text = f"{self.mouse_x:.2f}, {self.mouse_y:.2f}, {self.mouse_z:.2f}"
124        self.text.draw()
125
126    def on_mouse_motion(self, x: int, y: int, dx: int, dy: int):
127        """Move the light source with the mouse."""
128        # Convert to normalized coordinates
129        # (0.0, 0.0) is bottom left, (1.0, 1.0) is top right
130        self.mouse_x, self.mouse_y = x / self.width, y / self.height
131
132    def on_mouse_scroll(self, x: int, y: int, scroll_x: float, scroll_y: float):
133        """Zoom in/out with the mouse wheel."""
134        self.mouse_z += scroll_y * 0.05
135
136
137NormalMapping().run()