Inconsistant lighting on Geomipterrain

I was running some lighting engine tests when I noticed the lighting looked real wonky on my terrain, and I think there is an issue with how the normals are calculated for Geomipterrains.

In short, it doesn’t seem to match the rest of the lighting calculated in Panda at all.

Here’s some example screenshots. The first is a Geomipterrain, plus one reference model I made, plus one of the pack-in Panda models.

And here’s another, but instead of using a Geomipterrain, I am using an Egg generated from Blender/Chicken using the exact same heightmap.

You might notice that the lighting is consistent for every rendered object but the Geomipterrain. I checked out the C++ source, and I see it’s generating normals for each vertex based on the slope of surrounding heightmap pixels – That method looks correct to me, but still something isn’t right. I presume normal scaling occurs at some point, to take into account the Geomipterrain’s Z-scale parameter.

Any ideas?

Uh, are you sure that the light is not just coming from the other direction, or that maybe your normals are flipped?

you see that it from the shadows of the panda and the cubes…

Try it for yourself…

Zip of the models directory.

main.py:

# Lighting Test
from pandac.PandaModules import loadPrcFileData

loadPrcFileData("", '''
show-frame-rate-meter #t
''')

from pandac.PandaModules import *
from direct.gui.OnscreenText import OnscreenText

from flycam import Flycam
from wireGeom import wireGeom
import sys

import direct.directbase.DirectStart

font = loader.loadFont("cmss12")

def makeStatusLabel(i, str):
    text = OnscreenText(
      style=1, fg=(1,1,0,1), pos=(-1.3, 0.95 - (.05 * i)), font = font,
      align=TextNode.ALeft, scale = .05, mayChange = 1)
    text.setText (str)
    return text

makeStatusLabel (0, 'Keys:')
makeStatusLabel (1, 'Camera control: ASDW for movement, mouse to look')
makeStatusLabel (2, '[1] Show Geomipterrain')
makeStatusLabel (3, '[2] Show reference Egg')

# turn on autoshaders
render.setShaderAuto()

# turn off the base.camera mousenav stuff
base.disableMouse()

# instead, we're going to use our flycam class
flycam = Flycam ()
flycam.flycamNP.setPos (250, -250, 250)
flycam.flycamNP.lookAt (0, 0, 70)
flycam.acquireInput()
flycam.acquireCamera()

base.accept("escape", sys.exit)        #Escape quits

# Let's create a basic terrain
geomipterrain = GeoMipTerrain ('geomipterrain')
geomipterrain.setBlockSize (64)
geomipterrain.setNearFar (80, 100)
geomipterrain.setHeightfield ('models/heightmap.png')
geomipterrain.setAutoFlatten (GeoMipTerrain.AFMOff)
geomipterrain.setFocalPoint(flycam.flycamNP)
geomipterrain.setBruteforce(True)
geomipterrain.setMinLevel(3)

terrainRoot = geomipterrain.getRoot()
terrainRoot.setSz (100)  # give us some significant height differences
terrainRoot.setPos (Point3(-512/2, -512/2, 0)) # let's re-center the terrain
terrainRoot.reparentTo (render)

# generate terrain squares
geomipterrain.generate()

# now, let's assign a texture
terrainRoot.clearTexture()
ts = TextureStage('baseColor')
tex = loader.loadTexture('models/colormap.png')
tex.setMagfilter (Texture.FTLinear)
tex.setMinfilter (Texture.FTLinearMipmapLinear)
terrainRoot.setTexture(ts, tex)

# define a shiny material so we can also see some specular lighting on our
# terrain when autoshader is on
terrainMat = Material()
terrainMat.setShininess (12.5)
terrainMat.setAmbient (VBase4 (1, 1, 1, 1))
terrainMat.setDiffuse (VBase4 (1, 1, 1, 1))
terrainMat.setEmission (VBase4 (0, 0, 0, 0))
terrainMat.setSpecular (VBase4 (1, 1, 1, 1))
terrainRoot.setMaterial (terrainMat)

# load a generic flashlight model
flashlight = loader.loadModel ('models/flashlight.egg')

# we use the bump-objs to sanity check
bumpsphere = loader.loadModel ('models/bumpyobjects.egg')
bumpsphere.reparentTo (render)
bumpsphere.setScale (10)
bumpsphere.setPos (0, 20, 70)
bumpsphere.flattenStrong()

# load a pack-in model
panda = loader.loadModel ('models/panda.egg')
panda.reparentTo (render)
panda.setScale (5)
panda.setPos (0, 40, 70)
panda.flattenStrong()

# load a reference terrain
refterrain = loader.loadModel ('models/refterrain.egg')
refterrain.reparentTo (render)
refterrain.setPos (0, 0, 50)
refterrain.setScale (256)
refterrain.flattenStrong()
refterrain.hide()

# create a node to spin around for spot light
lightspinner = render.attachNewNode('spinner')
lightSpin = lightspinner.hprInterval(10.0,Point3(-360,0,0),startHpr=Point3(0,0,0))
lightSpin.loop()

# create a directional light
dlight = render.attachNewNode(DirectionalLight('directional'))
dlight.node().setColor(VBase4(1.0, 1.0, 1.0, 1.0))
#dlight.node().setDirection( Vec3( 1, 1, -2 ) )
dlight.setPos (100, 100, 100)
dlight.lookAt (0, 0, 0) # let Panda set dir for us
render.setLight(dlight)
dflashlight = flashlight.copyTo (dlight)
dflashlight.setScale (10)

# create a spinning spotlight
slight = lightspinner.attachNewNode(Spotlight('slight'))
lens = PerspectiveLens()
slight.node().setLens(lens)
slight.node().setColor(VBase4(1, 0, 0, 1))
slight.setPos(100, 100, 200)
slight.lookAt(0, 0, 90)
render.setLight(slight)
sflashlight = flashlight.copyTo (slight)
sflashlight.setScale (10)
sflashlight.setColor (VBase4(1, 0, 0, 1))

# set an ambient light
alight = render.attachNewNode (AmbientLight('ambient'))
alight.node().setColor(VBase4(0.1, 0.1, 0.1, 1.0))
render.setLight(alight)

def updateFrame(task):
    dt = ClockObject.getGlobalClock().getDt()
    geomipterrain.update()
    flycam.frameUpdate (dt)
    return task.cont

def hideObj(what):
    if what == 2:
        terrainRoot.hide()
        refterrain.show()
    if what == 1:
        terrainRoot.show()
        refterrain.hide()

base.taskMgr.add(updateFrame, 'updateFrame')
base.accept ('1', hideObj, [1])
base.accept ('2', hideObj, [2])

# do it
run()

Flycam.py:

# To change this template, choose Tools | Templates
# and open the template in the editor.

import math

from direct.directnotify.DirectNotifyGlobal import directNotify
from direct.showbase import DirectObject
from direct.showbase.InputStateGlobal import inputState
from direct.task.Task import Task
from pandac.PandaModules import *

class Flycam (DirectObject.DirectObject):

    def __init__ (self):
        # not really a whole lot this, just create a node to manipulate
        self.flycamNP = render.attachNewNode('flycam')

    def acquireInput (self):
        # set input watches
        self.forwardControl = inputState.watch ('camforward', 'w', 'w-up')
        self.reverseControl = inputState.watch ('camreverse', 's', 's-up')
        self.leftControl = inputState.watch ('camleft', 'a', 'a-up')
        self.rightControl = inputState.watch ('camright', 'd', 'd-up')
        self.accelControl = inputState.watch ('camaccel', 'shift', 'shift-up')
        self.accept ('v', self.toggleOOBE)

    def releaseInput (self):

        self.forwardControl.release()
        self.reverseControl.release()
        self.leftControl.release()
        self.rightControl.release()
        self.accelControl.release()
        self.ignoreAll() # unregister our events

    def acquireCamera (self, oobeCenter = None):
        base.camera.reparentTo(self.flycamNP)
        # make sure pointer is centered
        base.win.movePointer(0, 400, 300)
        self.oobeCenter = oobeCenter

    def releaseCamera (self):
        try:
            if self.oobeEnabled:
                self.toggleOOBE()
        except:
            pass
        base.camera.reparentTo(render)

    def toggleOOBE (self):
        # first, do we have a valid nodepath for our cull center?
        # todo
        try:
            self.oobeEnabled
        except:
            self.oobeEnabled = False

        if self.oobeEnabled:
            for cam in base.camList:
                cam.node().setCullCenter(NodePath())
            self.oobeEnabled = False
        else:
            for cam in base.camList:
                cam.node().setCullCenter(self.oobeCenter)
            self.oobeEnabled = True


    def frameUpdate (self, dt):
        inputFwd = inputState.isSet("camforward")
        inputBack = inputState.isSet("camreverse")
        inputLeft = inputState.isSet("camleft")
        inputRight = inputState.isSet("camright")
        inputAccel = inputState.isSet("camaccel")

        cam_sensitivity = 0.25 * 1.5
        speed = 0.35 * 8.0
        strafe_speed = 0.2 * 8.0

        if inputAccel:
            accel = 4.0
        else:
            accel = 1.0

        # Mouse Control
        md = base.win.getPointer(0)
        x = md.getX()
        y = md.getY()

        rotx, roty = 0, 0
        if base.win.movePointer(0, 400, 300):
            rotx -= (x - 400) * cam_sensitivity
            roty -= (y - 300) * cam_sensitivity
        if (roty < -80): roty = -80
        if (roty > 80): roty = 80

        self.flycamNP.setHpr(self.flycamNP.getH() + rotx, self.flycamNP.getP() + roty, 0)
        forward_dir = self.flycamNP.getNetTransform().getMat().getRow3(1)
        strafe_dir = self.flycamNP.getNetTransform().getMat().getRow3(0)
        forward_dir.normalize()
        strafe_dir.normalize()
        forward_dir *= speed * accel
        strafe_dir *= strafe_speed * accel

        if inputFwd:
            self.flycamNP.setPos(self.flycamNP.getPos() + forward_dir)
        if inputBack:
            self.flycamNP.setPos(self.flycamNP.getPos()-forward_dir)
        if inputLeft:
            self.flycamNP.setPos(self.flycamNP.getPos()-strafe_dir)
        if inputRight:
            self.flycamNP.setPos(self.flycamNP.getPos() + strafe_dir)

It looks like the normals are indeed flipped. If you turn on backface rendering and then maneuver the camera below the terrain, both meshes then match.

Why are the normals flipped for both the Geomipterrain and the rest of the meshes? That is what I do not understand.

EGG :

GMM :

def drawNormals(np,length=1):
    for c in np.findAllMatches('**/+GeomNode'):
        VLS=LineSegs('')
        VLS.setColor(1,0,0,1)
        VLS.setThickness(5)
        NLS=LineSegs('')
        NLS.setColor(0,1,0,1)
        for g in range(c.node().getNumGeoms()):
            vd=c.node().getGeom(g).getVertexData()
            vvr=GeomVertexReader(vd)
            nvr=GeomVertexReader(vd)
            vvr.setColumn('vertex')
            nvr.setColumn('normal')
            for r in range(vd.getNumRows()):
                v=vvr.getData3f()
                VLS.moveTo(v)
                NLS.moveTo(v)
                NLS.drawTo(v+nvr.getData3f()*length)
        c.attachNewNode(VLS.create()).setLightOff(1)
        c.attachNewNode(NLS.create()).setLightOff(1)

Thanks! I was just thinking maybe I was wrong about the normals, but couldn’t verify it. I was looking to see if Panda3d had a way to display that as part of debug info, but I can’t find it. Your Python snippet will do nicely though!

Okay, I think the issue is that the GeoMipTerrain::get_normal() method isn’t perturbing the coordinates enough. I don’t see why the calculation as-is wouldn’t work.

However, I think what is going is that the values returned by get_pixel_value() are just too small. You can see it in ynjh_jo’s screenshot, where the normals are pointing almost but not quite upright.

I’ll poke around with this more, but I have a lead now.

Actually, ynjh_jo’s script does not respect the scales and RescaleNormalAttrib (which is by default set to rescale the normals based on the scale).

If the normals would indeed point straight up (like his screenshot shows) the terrain would have had a flat color. So obviously, his snipplet does not respect scale.

True. I can see that now when I run the code myself, as the line segments it builds are attached directly to the geoms.

But, something isn’t right with the normals WRT Geomipterrains, either due to scale or something else.

What result do you get when you run my above code?

If there is a rescaling operation occurring as a standard scene state, I assume this is why the normals are stored unscaled in the raw vertex data?

Well, I don’t know. It looks like the normals are flipped in Y axis or so.

The normals just look odd overall, due to the Z-axis scale applied on the entire geomipterrain mesh.

Here’s a normal dump from a vertex reader in Python code:

VBase3(-0.00784276, -0.00588207, 0.999952)
VBase3(-0.00980298, -0.00980298, 0.999904)
VBase3(-0.00784284, -0.00392142, 0.999962)

So I got to looking at the function that calculates the normals, and checked some other popular algorithms. (Here’s one reference.) From what I understand of it, you shortcut the usual cross-product-of-edges normalizing algorithm by treating the heightmap as a 2D XY plane. The cross-products then can be reduced to the formula:

Nx = Cz - Az
Ny = Dz - Bz
Nz = 2 * Sz

(A,B,C,D being the uniform grid points and Sz a scale determining where 1 unit of height = 1 unit of world space.)

That’s what is being used in the get_normal() function, but not exactly. What is currently in the code is:

LVector3f normal(drx * 0.5, dry * 0.5, 1);

Which I don’t entirely understand. drx/dry are uniform values from the aforementioned grid, but the z value isn’t scaled, which kind of breaks the shortcut. So I end up with weird normals.

I’m not very good at math, so I decided to simplify the problem. First, I got rid of the node Z-scale. I do this by introducing a terrain height scale into the GMT class, which reflects the world coordinates for the Z axis. Since 1 heightmap pixel = 1 panda3d world unit for the other two axis, it matches the same ratio as used in the generate_block() method.

Then, in get_normal(), I factor in this ratio as per the above formula:

LVector3f normal(drx, dry, 2.0f * (1.0f / _pixel_zscale));

The result is starting to look a LOT more like what I would expect: (Using ynjh_jo’s method, since now the GMT scale is uniformly 1.)

It’s not perfect, but I understand it will never be perfect because the normals are not being weighed against their neighbors. (I’ve seen some discussion about an 8-way Sobel filter to improve the smoothness of the normals generated using a heightmap shortcut, but that’s complicating it a bit too much for my comfort.)

If you are wondering why I am going to all of this trouble and not just ignoring the normals and using a lightmap, then here is the reason why:

I’ve already got the tangent/bitangent calculations in place for normal mapping with GeoMipTerrains, I just needed normals that made sense.

Thanks everybody for reading/replying and being patient with me! I am getting pretty close here to what I want.

It’s simply flipped along X. I’ve tested it and it’s correct.

You’re right, my script disregards scale. To make it right, you just need to apply the counter scale twice.

Good catch. You’re right, the X coordinates were flipped.

Here’s the result now:

That’s just about perfect!

If anyone else would like to check out my work, a patch for geoMipTerrain.cxx/.I/.H is at the end of this post.

Only downside? I’m introducing yet another configuration option, ie: SetHeightmapScale(). However, I do not see a way around this, unless generate_block() is modified to create all axis on a unit scale.

Once you’ve set your heightmap scale appropriately, then all future scaling operations to the terrain root node will correctly modify the normals.

Index: geoMipTerrain.I
===================================================================
RCS file: /cvsroot/panda3d/panda/src/grutil/geoMipTerrain.I,v
retrieving revision 1.18
diff -u -r1.18 geoMipTerrain.I
--- geoMipTerrain.I	11 Apr 2009 13:37:24 -0000	1.18
+++ geoMipTerrain.I	12 Aug 2009 19:10:02 -0000
@@ -28,6 +28,7 @@
   _block_size = 16;
   _max_level = 4; // Always log(_block_size) / log(2.0)
   _min_level = 0;
+  _pixel_zscale = 1.0 ; // Scaling factor for pixel values returned
   _factor = 100.0;
   _near = 16.0;
   _far = 128.0;
@@ -267,6 +268,16 @@
 }
 
 ////////////////////////////////////////////////////////////////////
+//     Function: GeoMipTerrain::set_heightmap_scale
+//       Access: Published
+//  Description: Sets the Z-scaling factor of the heightmap
+////////////////////////////////////////////////////////////////////
+INLINE void GeoMipTerrain::
+set_heightmap_scale(float input_zscale) {
+  _pixel_zscale = input_zscale;
+}
+
+////////////////////////////////////////////////////////////////////
 //     Function: GeoMipTerrain::is_dirty
 //       Access: Published
 //  Description: Returns a bool indicating whether the terrain is
Index: geoMipTerrain.cxx
===================================================================
RCS file: /cvsroot/panda3d/panda/src/grutil/geoMipTerrain.cxx,v
retrieving revision 1.19
diff -u -r1.19 geoMipTerrain.cxx
--- geoMipTerrain.cxx	21 Jun 2009 06:44:49 -0000	1.19
+++ geoMipTerrain.cxx	12 Aug 2009 19:09:34 -0000
@@ -49,6 +49,8 @@
   nassertr(mx < (_xsize - 1) / _block_size, NULL);
   nassertr(my < (_ysize - 1) / _block_size, NULL);
 
+
+
   unsigned short center = _block_size / 2;
   unsigned int vcounter = 0;
   
@@ -118,10 +120,11 @@
                                   / double(_ysize) * _color_map.get_y_size()));
           cwriter.add_data4f(LCAST(float, color));
         }
-        vwriter.add_data3f(x - 0.5 * _block_size, y - 0.5 * _block_size, get_pixel_value(mx, my, x, y));
+        vwriter.add_data3f(x - 0.5 * _block_size, y - 0.5 * _block_size, get_pixel_value(mx, my, x, y) * _pixel_zscale);
         twriter.add_data2f((mx * _block_size + x) / double(_xsize - 1),
                            (my * _block_size + y) / double(_ysize - 1));
         nwriter.add_data3f(get_normal(mx, my, x, y));
+
         if (x > 0 && y > 0) {
           // Left border
           if (x == level && ljunction) {
@@ -320,9 +323,9 @@
   if (ny < 0) ny++;
   if (px >= int(_xsize)) px--;
   if (py >= int(_ysize)) py--;
-  double drx = get_pixel_value(px, y) - get_pixel_value(nx, y);
+  double drx = get_pixel_value(nx, y) - get_pixel_value(px, y);
   double dry = get_pixel_value(x, py) - get_pixel_value(x, ny);
-  LVector3f normal(drx * 0.5, dry * 0.5, 1);
+  LVector3f normal(drx, dry, 2.0f * (1.0f / _pixel_zscale));
   normal.normalize();
 
   return normal;
Index: geoMipTerrain.h
===================================================================
RCS file: /cvsroot/panda3d/panda/src/grutil/geoMipTerrain.h,v
retrieving revision 1.9
diff -u -r1.9 geoMipTerrain.h
--- geoMipTerrain.h	11 Apr 2009 13:37:24 -0000	1.9
+++ geoMipTerrain.h	12 Aug 2009 19:10:22 -0000
@@ -96,6 +96,7 @@
   INLINE unsigned short get_min_level();
   INLINE bool is_dirty();
   INLINE void set_factor(float factor);
+  INLINE void set_heightmap_scale(float input_zscale);
   INLINE void set_near_far(double input_near, double input_far);
   INLINE void set_near(double input_near);
   INLINE void set_far(double input_far);
@@ -142,6 +143,7 @@
   bool _use_near_far; // False to use the _factor, True to use the _near and _far values.
   unsigned short _block_size;
   unsigned short _max_level; // Highest level possible for this block size
+  float _pixel_zscale ; // Scaling factor for pixel values returned
   bool _bruteforce;
   NodePath _focal_point;
   bool _focal_is_temporary;