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()