Color gradient scene background

I would like to add a color gradient background to my scene, to have something looking more or less like this:

I guess it is not possible to do it using base.setBackgroundColor, so I had a look to sky box and found several topics about it. However it is doing more stuff than I want and I would like to avoid using egg file. Is it possible ?

One approach might be to just create a box–a little like a full skybox, but simpler–with only a single texture carefully UV-mapped onto it.

Otherwise, you could perhaps create a quad sized to fit the view of your camera, and apply the gradient to that quad, whether by texture or by shader. This may require that you resize the quad when the window is resized.

In either case, I would suggest putting the object in to the “background” bin and deactivating depth -writing and -testing. That way it should appear behind everything else, regardless of physical position.

Using a bit of billboard- and compass-effect trickery applied to a simple procedurally-generated piece of geometry (combined with the binning and depth-disabling as suggested by @Thaumaturge), I came up with the following:

from panda3d.core import *
from direct.showbase.ShowBase import ShowBase
import array


def create_gradient(sky_color, ground_color, horizon_color=None):

    vertex_format = GeomVertexFormat()
    array_format = GeomVertexArrayFormat()
    array_format.add_column(InternalName.get_vertex(), 3, Geom.NT_float32, Geom.C_point)
    vertex_format.add_array(array_format)
    array_format = GeomVertexArrayFormat()
    array_format.add_column(InternalName.make("color"), 4, Geom.NT_uint8, Geom.C_color)
    vertex_format.add_array(array_format)
    vertex_format = GeomVertexFormat.register_format(vertex_format)

    vertex_data = GeomVertexData("prism_data", vertex_format, GeomEnums.UH_static)
    vertex_data.unclean_set_num_rows(6)
    # create a simple, horizontal prism;
    # make it very wide to avoid ever seeing its left and right sides;
    # one edge is at the "horizon", while the two other edges are above
    # and a bit behind the camera so they are only visible when looking
    # straight up
    values = array.array("f", [
        -1000., -50., 86.6,
        -1000., 100., 0.,
        -1000., -50., -86.6,
        1000.,-50., 86.6,
        1000., 100., 0.,
        1000., -50., -86.6
    ])
    pos_array = vertex_data.modify_array(0)
    memview = memoryview(pos_array).cast("B").cast("f")
    memview[:] = values

    color1 = tuple(int(round(c * 255)) for c in sky_color)
    color3 = tuple(int(round(c * 255)) for c in ground_color)

    if horizon_color is None:
        color2 = tuple((c1 + c2) // 2 for c1, c2 in zip(color1, color3))
    else:
        color2 = tuple(int(round(c * 255)) for c in horizon_color)

    values = array.array("B", (color1 + color2 + color3) * 2)
    color_array = vertex_data.modify_array(1)
    memview = memoryview(color_array).cast("B")
    memview[:] = values

    tris_prim = GeomTriangles(GeomEnums.UH_static)
    indices = array.array("H", [
        0, 2, 1,  # left triangle; should never be in view
        3, 4, 5,  # right triangle; should never be in view
        0, 4, 3,
        0, 1, 4,
        1, 5, 4,
        1, 2, 5,
        2, 3, 5,
        2, 0, 3
    ])
    tris_array = tris_prim.modify_vertices()
    tris_array.unclean_set_num_rows(24)
    memview = memoryview(tris_array).cast("B").cast("H")
    memview[:] = indices

    geom = Geom(vertex_data)
    geom.add_primitive(tris_prim)
    node = GeomNode("prism")
    node.add_geom(geom)
    # the compass effect can make the node leave its bounds, so make them
    # infinitely large
    node.set_bounds(OmniBoundingVolume())
    prism = NodePath(node)
    prism.set_light_off()
    prism.set_bin("background", 0)
    prism.set_depth_write(False)
    prism.set_depth_test(False)

    return prism


class MyApp(ShowBase):

    def __init__(self):

        ShowBase.__init__(self)

        # set up a light source
        p_light = PointLight("point_light")
        p_light.set_color((1., 1., 1., 1.))
        self.light = self.camera.attach_new_node(p_light)
        self.light.set_pos(5., -100., 7.)
        self.render.set_light(self.light)

        # add a model as visual reference
        smiley = self.loader.load_model("smiley")
        smiley.reparent_to(self.render)
        smiley.set_y(10.)

        # define the colors at the top ("sky"), bottom ("ground") and center
        # ("horizon") of the background gradient
        sky_color = (0, 0, 1., 1.)
        horizon_color = (.5, 0, .5, 1.)  # optional
        ground_color = (0, 1., 0, 1.)
        self.background_gradient = create_gradient(sky_color, ground_color)#, horizon_color)
        # looks like the background needs to be parented to an intermediary node
        # to which a compass effect is applied to keep it at the same position
        # as the camera, while being parented to render
        pivot = self.render.attach_new_node("pivot")
        effect = CompassEffect.make(self.camera, CompassEffect.P_pos)
        pivot.set_effect(effect)
        self.background_gradient.reparent_to(pivot)
        # now the background model just needs to keep facing the camera (only
        # its heading should correspond to that of the camera; its pitch and
        # roll need to remain unaffected)
        effect = BillboardEffect.make(
            Vec3.up(),
            False,
            True,
            0.,
            NodePath(),
            # make the background model face a point behind the camera
            Point3(0., -10., 0.),
            False
        )
        self.background_gradient.set_effect(effect)


app = MyApp()
app.run()

You can blend three different colors to create the gradient; these are set as vertex colors, so you don’t need a texture.

It seems to work for the most part, although some parts of the geometry (the left and right sides of the prism) come into view when panning the camera, which shouldn’t happen. Not sure what the reason is, but perhaps this doesn’t occur when using a custom camera controller. Perhaps I’ll test that later.

EDIT:
Looks like I got it to work correctly now.

Anyway, hopefully this suits your needs :slight_smile: .

2 Likes

Rather than a billboard effect, could you not just reparent the object to the camera?

Sure, but then the gradient will appear static (relative to the camera), while my code sample allows the camera to look up and down along the gradient, which is more consistent with how a skybox is supposed to work, I think.

Instead of a compass effect, I just tried using a task that keeps the geometry centered on the camera (using self.background_gradient.set_pos(self.cam.get_pos())) but that results in very jittery behavior :frowning: .

Aah, good point.

Myself, I might be tempted to write a shader to generate the gradient–but that’s likely overkill in this situation!

(Although perhaps not by much: it shouldn’t be difficult to implement a single quad parented to the camera and running a shader that observes the camera’s tilt.)

Instead of a compass effect, I just tried using a task that keeps the geometry centered on the camera (using self.background_gradient.set_pos(self.cam.get_pos())) but that results in very jittery behavior :frowning: .

Yeah, one can get hit by order-of-operation issues when attempting such things, I’ve found. It might work better if you were to use a single task controlling both the camera and the positioning of the geometry, I think–but of course that means giving up the default camera controller.

Yup, that’s probably the only reliable way–but that’s something I’ll have to try next year then :wink: .

Happy New Year :slight_smile: !

1 Like

Happy New Year to you too! I hope that it’s a happy one for you! :slight_smile:

1 Like

You have two separate questions:

  1. You want the background to inherit the camera’s position, but not the rotation.
    There’s two ways to do this:
    Parent your mesh (which should be a dome or uv sphere, a box will have some minor artifacts) to the camera, then do node.set_billboard_point_eye() to not inherit camera’s rotations OR position the mesh to the camera each frame manually with node.set_pos(base.cam) inside a Panda3D Task.

  2. You want the background mesh to be correctly made. You can just reuse the “smiley.egg” model that comes with panda3d for that or procedurally generate it or load a 3d model file of an uv unwrapped uv sphere. This is not something I would use procedural geometry for, seems like a waste of time and lines of code.

Of course you can also do some tricks with billboards facing the camera like with older 3d games like Shenmue or Mario 64 but that’s also wasted time.

Color gradient may be achieved with either vertex colors or a gradient texture file. The latter is easier to edit and both use very tiny system resources.

A minor note here: There needn’t be such artefacts, I believe, especially for so simple a thing as a gradient. However, a solution that lacks said artefacts is more complex to build, I think.

Got it working, it seems! Parenting the background model to an intermediate “pivot” node (to which a compass effect is applied to keep it at the position of the camera) gives me the intended behavior :slight_smile: .

That didn’t seem to work; the result was the same as just parenting the mesh to the camera without any effects applied. But it’s OK, I seem to have gotten it to work. Thanks for trying to help!

The procedurally generated mesh in my code is as simple as it can get and it didn’t take me much time at all :slight_smile: .

And the former can be edited fairly easily at runtime, if you know how :wink: .

@Epihaius This is amazing ! Thank you very much :heart_eyes:

Here is the result I get using your implementation:

However, no matter the color I use, I cannot get a color gradient as sharp as in the first screenshot, and I must admit it is not clear to me what to change to get the intended rendering :confused:

NB: I have implemented my own camera control, and the user is not supposed to look right up nor right down, so I think the 3 gradient control points should be “closer” to the horizon.

2 Likes

I would guess–in line with your “NB”–that your gradient is simply stretched over too tall a surface. Your suggestion of moving the points vertically closer to horizon-level should work.

However, if that results in the top or bottom of the gradient-object becoming visible, then you could also add some intermediate points within the object that have something close to the intended gradient end-colours. That should intensify the resulting gradient without making the object itself shorter.

Here is a new version of the code that makes it possible to “squash” the gradient by specifying an offset (for both the top and bottom edges), ranging from 0.0 to 1.0:

from panda3d.core import *
from direct.showbase.ShowBase import ShowBase
import array
from math import pi, sin, cos


def create_gradient(subdiv, offset, sky_color, ground_color,
                    top_color=None, bottom_color=None):

    subdiv = max(1, subdiv)  # number of subdivisions
    offset = max(0., min(1., offset))  # top and bottom offset

    vertex_format = GeomVertexFormat()
    array_format = GeomVertexArrayFormat()
    array_format.add_column(InternalName.get_vertex(), 3, Geom.NT_float32, Geom.C_point)
    vertex_format.add_array(array_format)
    array_format = GeomVertexArrayFormat()
    array_format.add_column(InternalName.make("color"), 4, Geom.NT_uint8, Geom.C_color)
    vertex_format.add_array(array_format)
    vertex_format = GeomVertexFormat.register_format(vertex_format)

    vertex_data = GeomVertexData("prism_data", vertex_format, GeomEnums.UH_static)
    vertex_data.unclean_set_num_rows(4 + subdiv * 2)
    # create a simple, horizontal prism;
    # make it very wide to avoid ever seeing its left and right sides;
    # one edge is at the "horizon", while the two other edges are above
    # and a bit behind the camera so they are only visible when looking
    # straight up
    values = array.array("f", (-1000., -50., 86.6, 1000., -50., 86.6))
    offset_angle = pi / 1.5 * offset
    delta_angle = (pi / .75 - offset_angle * 2.) / (subdiv + 1)

    for i in range(subdiv):
        angle = pi / 3. + offset_angle + delta_angle * (i + 1)
        y = -cos(angle) * 100.
        z = sin(angle) * 100.
        values.extend((-1000., y, z, 1000., y, z))

    values.extend((-1000., -50., -86.6, 1000., -50., -86.6))
    pos_array = vertex_data.modify_array(0)
    memview = memoryview(pos_array).cast("B").cast("f")
    memview[:] = values

    color1 = tuple(int(round(c * 255)) for c in sky_color)
    color2 = tuple(int(round(c * 255)) for c in ground_color)
    top_col = tuple(int(round(c * 255)) for c in top_color) if top_color else color1
    btm_col = tuple(int(round(c * 255)) for c in bottom_color) if bottom_color else color2
    values = array.array("B", top_col * 2)
    s = subdiv + 1

    # interpolate the colors
    for i in range(subdiv):
        f1 = (subdiv - i) / s
        f2 = 1. - f1
        color = tuple(int(round(c1 * f1 + c2 * f2))
            for c1, c2 in zip(color1, color2))
        values.extend(color * 2)

    values.extend(btm_col * 2)
    color_array = vertex_data.modify_array(1)
    memview = memoryview(color_array).cast("B")
    memview[:] = values

    tris_prim = GeomTriangles(GeomEnums.UH_static)
    indices = array.array("H", (0, 3, 1, 0, 2, 3))

    for i in range(subdiv + 1):
        j = i * 2
        indices.extend((j, 3 + j, 1 + j, j, 2 + j, 3 + j))

    j = (subdiv + 1) * 2
    indices.extend((j, 1, 1 + j, j, 0, 1))
    tris_array = tris_prim.modify_vertices()
    tris_array.unclean_set_num_rows((subdiv + 3) * 6)
    memview = memoryview(tris_array).cast("B").cast("H")
    memview[:] = indices

    geom = Geom(vertex_data)
    geom.add_primitive(tris_prim)
    node = GeomNode("prism")
    node.add_geom(geom)
    # the compass effect can make the node leave its bounds, so make them
    # infinitely large
    node.set_bounds(OmniBoundingVolume())
    prism = NodePath(node)
    prism.set_light_off()
    prism.set_bin("background", 0)
    prism.set_depth_write(False)
    prism.set_depth_test(False)

    return prism


class MyApp(ShowBase):

    def __init__(self):

        ShowBase.__init__(self)

        # set up a light source
        p_light = PointLight("point_light")
        p_light.set_color((1., 1., 1., 1.))
        self.light = self.camera.attach_new_node(p_light)
        self.light.set_pos(5., -100., 7.)
        self.render.set_light(self.light)

        # add a model as visual reference
        smiley = self.loader.load_model("smiley")
        smiley.reparent_to(self.render)
        smiley.set_y(10.)

        # define the colors at the top ("sky") and bottom ("ground")
        sky_color = (0, 0, 1., 1.)
        ground_color = (0, 1., 0, 1.)
        top_color = (1., 1., 1., 1.)  # color above and behind camera
        bottom_color = (0., .5, 0., 1.)  # color below and behind camera
        self.background_gradient = create_gradient(2, .75, sky_color, ground_color,
            top_color, bottom_color)
        # looks like the background needs to be parented to an intermediary node
        # to which a compass effect is applied to keep it at the same position
        # as the camera, while being parented to render
        pivot = self.render.attach_new_node("pivot")
        effect = CompassEffect.make(self.camera, CompassEffect.P_pos)
        pivot.set_effect(effect)
        self.background_gradient.reparent_to(pivot)
        # now the background model just needs to keep facing the camera (only
        # its heading should correspond to that of the camera; its pitch and
        # roll need to remain unaffected)
        effect = BillboardEffect.make(
            Vec3.up(),
            False,
            True,
            0.,
            NodePath(),
            # make the background model face a point behind the camera
            Point3(0., -10., 0.),
            False
        )
        self.background_gradient.set_effect(effect)


app = MyApp()
app.run()

You can also specify the number of mesh subdivisions (along the height of the gradient), although this is probably not so useful in your use case. You need at least 2 subdivisions for the offset to take effect.
There is no more option to specify a horizon color, but it didn’t seem all that useful anyway.

Hope you like it :slight_smile: .

2 Likes

I believe the opposite is the case.

You can see the corners a bit. I’ve always experienced this artifact with vertex colors, even in modern games where vertex colors are still used to add some shading to the terrain. For example in Spyro Reignited, where it’s mixed with vegetation mesh and textures and is subtle but visible to the trained eye.
Maybe there’s a different vertex color interpolation mode to overcome this, never heard of it.

That’s true–if you’re only using vertex colours and the standard shaders or fixed-function pipeline. However, a shader can be written that accounts for the issue.

See here: This sky is rendered within a cube, too, via a custom shader:
(Simple flat-shading to show the box-shape on the top, actual shading on the bottom, and both showing a bottom-corner for reference.)

The shader-code for this is a bit on the arcane side, however, and likely overkill for the purposes of the original poster!

@Epihaius Thank you again ! It is much better now :slight_smile: Also perfect !

Last question, the color of the gradient is affected by the ambient light, so that it is impossible to get full white using sky_color = (1., 1., 1., 1.). Do you think it is possible to change this behavior to make the sky color independent from lighting ?

You should be able to do that be calling “setLightOff()” on your sky-object, if I’m not much mistaken.

If that alone doesn’t do it, try adding “1” as a parameter to the call–that parameter being an override-value, allowing it to potentially circumvent other calls that might affect it.

That is, something like this:

self.background_gradient.setLightOff()

Or, if that alone doesn’t work, then something like this:

self.background_gradient.setLightOff(1)

Hmmm, that method is already being called on the model as it is being created in my code, although not with an override priority.

But wait, I seem to have made a mistake in the color-interpolation code, such that the outermost subdivision edges (those farthest away from the “horizon”) already have interpolated colors, while they should have the actual sky_color and ground_color applied without any interpolation.
So this might be what is being observed instead of an ambient light affecting the model.

@duburcqa Could you change the following code segment:

    s = subdiv + 1

    # interpolate the colors
    for i in range(subdiv):
        f1 = (subdiv - i) / s

into this one, please:

    x = 0 if subdiv == 1 else 1
    s = subdiv - x

    # interpolate the colors
    for i in range(subdiv):
        f1 = (subdiv - x - i) / s

Let me know if this fixes the problem.

1 Like

@Epihaius It seems there is something wrong with my own code, since your example is working fine, but not mine I don’t know why.

EDIT: I found the issue. It was on my side. I was using self.render.set_attrib(LightRampAttrib.makeHdr0()). Does it make sense to you ?

Now I get the expected result:

It is perfect ! Thank you all :partying_face:

2 Likes