The program code for all parts can be downloaded from github. Note that the full code is not found below - to run this, use github code.
Having an Angle
To find out if a surface is pointing towards the viewer (and should be drawn) or not, we need to figure out the angle between the vector from the viewer to the plane and the vector orthogonal to the plane. In the picture below that would be vectors r and n:
Vector r from viewer and its direction vector u, crossing the plane, and vector n, the vector orthogonal to the plane. Source: superprof resources.
Now, how do we get vector n? It helps a bit that the vector is, obviously, the same at all locations on the plane, because, well, it's a plane and not some curvy surface. We can use one of the corners of the plane and the two vectors from that corner to the closest two other corners to calculate n by taking a cross product of these two vectors:
def updateSurfaceCrossProductVector(self): # calculate cross product vector for each surface using rotatedNodes # always use vectors (1, 0) and (1, 2) (numbers representing nodes) # numpy "cross" was terribly slow, calculating directly as below takes about 10 % of the time. for surface in self.surfaces: vec_A = self.rotatedNodes[surface.nodes[2], 0:3] - self.rotatedNodes[surface.nodes[1], 0:3] vec_B = self.rotatedNodes[surface.nodes[0], 0:3] - self.rotatedNodes[surface.nodes[1], 0:3] vec_Cross = ([ vec_B[1] * vec_A[2] - vec_B[2] * vec_A[1], vec_B[2] * vec_A[0] - vec_B[0] * vec_A[2], vec_B[0] * vec_A[1] - vec_B[1] * vec_A[0] ]) surface.setCrossProductVector(vec_Cross)
Numpy does have a cross function, but it uses way too much time figuring out the shapes of the vectors etc., while we already know they will always be ordinary 3D vectors. Hence, it is much faster to calculate the cross product vector as above. This is done for all surfaces of the object.
Now, you may notice that there are actually two (unit length) orthogonal vectors for the surface; one pointing "up" and one pointing "down". How do we know which one to calculate? In the previous part I explained that each surface must be defined by adding the nodes clockwise (from viewing direction) - this ensures that the vec_A and vec_B above always produce a vector pointing "out" of the object.
Next, we need to calculate the angle. Actually, we do not need the angle itself; we just need to know whether the angle is such that the surface is pointing towards the viewer. This is the math we need:
Determining the angle between two vectors. Source: superprof resources.
And this is how it is done in the code:
def updateSurfaceAngleToViewer(self): # calculate acute angle between surface plane and Viewer # surface plane cross product vector and Viewer vector both from node 1. for surface in (surf for surf in self.surfaces if surf.visible == 1): vec_Viewer = self.rotatedNodes[surface.nodes[1], 0:3] surface.setAngleToViewer(vec_Viewer) if surface.angleToViewer > 0 or surface.showBack == 1: surface.setVisible(1) else: surface.setVisible(0) def setAngleToViewer(self, vec_Viewer): if self.crossProductLen > 0 and vec_Viewer.any() != 0: # instead of true angle calculation using asin and vector lengths, a simple np.vdot is sufficient to find the sign (which defines if surface is visible) # self.angleToViewer = math.asin(np.dot(self.crossProductVector, vec_Viewer) / (self.crossProductLen * np.linalg.norm(vec_Viewer))) self.angleToViewer = np.dot(self.crossProductVector, vec_Viewer)
So, for each surface, I am defining the vector from the viewer (who sits at [0,0,0]) to the same corner of the surface I defined the orthogonal cross product vector for, and then taking a dot product. That's basically the dividend part of the lowest formula above. Since we know that the divisor, which is the product of the lengths of the vectors, is always positive, we know from the sign of the dividend whether we are taking an arcsin of a positive or a negative number. And, we also know that the number will be between -1 and 1. All this means the angle will have the same sign as the dividend. If the angle is positive, the surface is pointing towards the viewer, and it will have its Visible property set to one. We don't really know the actual size of the angle, but it does not matter for this purpose.
Note the property showBack above. If a surface is defined to "show its back" then it will always be visible, no matter what the sigh of the angle is.
Shading
We can use the same technique to shade a surface using a light source. Instead of calculating the angle between the vector from the viewer and the surface, we need to calculate the angle between a vector from some light source and the surface. The process is identical, but in this case, we are interested in the actual angle. With the angle, we can define the shading so that if the angle shows the surface is pointing directly towards the light source, we set the "shading factor" to one; if it is pointing away, we set it to something small (minShade); and in between, in proportion to the angle.
def updateSurfaceAngleToLightSource(self, lightPosition): # calculate acute angle between surface plane and light source, similar to above for Viewer. # this is used to define shading and shadows; needed for visible surfaces using shading AND all surfaces, if shadow to be drawn. for surface in (surf for surf in self.surfaces if surf.visible == 1 and self.minShade < 1.0): surface.setLightSourceVector(self.rotatedNodes[surface.nodes[1], 0:3] - lightPosition) surface.setAngleToLightSource() def updateSurfaceColorShade(self): # calculate shade for surface. for surface in (surf for surf in self.surfaces if surf.visible == 1): surface.setColorShade(self.minShade) if surface.showBack == 1: surface.setBackColorShade(self.minShade) def setAngleToLightSource(self): if self.crossProductLen > 0 and self.lightSourceVector.any() != 0: # instead of true angle calculation using asin and vector lengths, a simple np.vdot is sufficient to find the sign (which defines if surface is shadowed) # self.angleToLightSource = math.asin(np.dot(self.crossProductVector, self.lightSourceVector) / (self.crossProductLen * np.linalg.norm(self.lightSourceVector))) / (np.pi / 2) self.angleToLightSource = np.dot(self.crossProductVector, self.lightSourceVector) def setColorShade(self, minShade): if self.angleToLightSource <= 0: self.colorShade = minShade else: self.colorShade = minShade + (1.0 - minShade) * math.asin(self.angleToLightSource / (self.crossProductLen * self.vectorLen(self.lightSourceVector))) / (np.pi / 2) def colorRGB(self): use_color = ([round(self.colorShade * x, 0) for x in self.color]) # apply shading return use_color def vectorLen(self, vector): # equivalent to numpy.linalg.norm for a 3D real vector, but much faster. math.sqrt is faster than numpy.sqrt. return math.sqrt(vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2])
Lastly, in colorRGB, I adjust the surface color by multiplying it with colorShade.
Now I have the same cube as in the previous part, but with shading. What can not be seen is that now the surfaces in the back side not visible to the viewer are not drawn at all.
Need for Speed 2: Don't Do It
In the previous part I explained how precalculating some of the CPU intensive operations was used on the Amiga to make code run faster. Of course, as above, often the fastest code is the code which is not run at all. With Python, I am using some of these methods, but also taking care not to sacrifice how the program can be used. For example, in the original Amiga demo I dropped the ability to actually rotate the objects around all three axes and settled for the Y (vertical) axis, which has several advantages:
- calculating the rotation matrix becomes much simpler, when X and Z angles are zero - less calculations needed for rotations.
- calculating the shadows becomes much easier when the ground is always at Y coordinate zero.
- the ground needs no drawing at all, but it can be shown by setting the background color using the Copper co processor (almost no CPU time needed).
Next: Part IV: Cityscape.
No comments:
Post a Comment