Setting multiple textures on different parts of a node path

I am trying to create playing cards as cube-like objects from triangles with a face, back and sides.
I want to assign different textures to the front and back of the card. However, running my example code below results in just the back texture being drawn and the rest of the card being black. Surprisingly, even the side walls of the card become black, although they should have no texture at all.

If I just apply a texture to the front of the card, the side walls are correctly drawn in grey and the back remains white (the color I set for the front and back over which the texture is put).

Here is my CardFactory code:

# Standard imports
from typing import Dict, Union

# External imports
from panda3d.core import *


class CardDimensions:
    def __init__(self, width=6.35, height=8.89, thickness=0.2):
        self.width = width
        self.height = height
        self.thickness = thickness


class CardNodePathFactory:
    def __init__(self,
                 parent,
                 dimensions: CardDimensions,
                 textures_front_paths: Dict[int, str],
                 textures_back_paths: Union[str, Dict[int, str]]):
        self.parent = parent
        self.dimensions = dimensions
        self.textures_front = {uuid: loader.loadTexture(path) for uuid, path in textures_front_paths.items()}
        if isinstance(textures_back_paths, str):
            texture_back = loader.loadTexture(textures_back_paths)
            self.textures_back = {uuid: texture_back for uuid in textures_front_paths}
        else:
            self.textures_back = {uuid: loader.loadTexture(path) for uuid, path in textures_back_paths.items()}

    def get_card_node_path(self, uuid: int) -> NodePath:
        array = GeomVertexArrayFormat()
        array.addColumn("vertex", 3, Geom.NTFloat32, Geom.CPoint)
        array.addColumn("color", 3, Geom.NTFloat32, Geom.CColor)
        array.addColumn("normal", 3, Geom.NT_float32, Geom.CNormal)
        array.addColumn("texcoord.front", 2, Geom.NT_float32, Geom.CTexcoord)
        array.addColumn("texcoord.back", 2, Geom.NT_float32, Geom.CTexcoord)
        vertex_format = GeomVertexFormat()
        vertex_format.addArray(array)
        vertex_format = GeomVertexFormat.registerFormat(vertex_format)

        vertexCount = 0

        dimensions = [self.dimensions.width / 2, self.dimensions.thickness / 2, self.dimensions.height / 2]

        tris = GeomTriangles(Geom.UHStatic)

        vertex_data = GeomVertexData("vertex_data", vertex_format, Geom.UHStatic)

        vertex_writer = GeomVertexWriter(vertex_data, "vertex")
        color_writer = GeomVertexWriter(vertex_data, "color")
        normal_writer = GeomVertexWriter(vertex_data, "normal")
        texcoord_front_writer = GeomVertexWriter(vertex_data, "texcoord.front")
        texcoord_back_writer = GeomVertexWriter(vertex_data, "texcoord.back")

        for i in range(3):
            for direction in (-1, 1):
                normal = VBase3()
                normal[i] = direction

                if i == 1:
                    rgb = [1., 1., 1.]
                else:
                    rgb = [0.5, 0.5, 0.5]

                r, g, b = rgb
                color = (r, g, b, 1.)

                for a, b in ((-1., -1.), (-1., 1.), (1., 1.), (1., -1.)):
                    vertex = VBase3()

                    vertex[i] = direction * dimensions[i]
                    vertex[(i + direction) % 3] = a * dimensions[(i + direction) % 3]
                    vertex[(i + direction * 2) % 3] = b * dimensions[(i + direction * 2) % 3]

                    vertex_writer.addData3f(vertex)
                    color_writer.addData4f(color)
                    normal_writer.addData3f(normal)

                    if i == 1:
                        if direction == -1:
                            texcoord_front_writer.addData2f((a + 1) / 2, (b + 1) / 2)
                            texcoord_back_writer.addData2f(0, 0)
                        else:
                            texcoord_front_writer.addData2f(0, 0)
                            texcoord_back_writer.addData2f((b + 1) / 2, (a + 1) / 2)
                    else:
                        texcoord_front_writer.addData2f(0, 0)
                        texcoord_back_writer.addData2f(0, 0)

                vertexCount += 4

                tris.addVertices(vertexCount - 2, vertexCount - 3, vertexCount - 4)
                tris.addVertices(vertexCount - 4, vertexCount - 1, vertexCount - 2)

        geom = Geom(vertex_data)
        geom.addPrimitive(tris)

        node = GeomNode("geom_node")
        node.addGeom(geom)

        card_np = self.parent.attachNewNode(node)
        card_np.setPos(0, 0, 0)

        # set textures
        if self.textures_front[uuid] is not None:
            ts_front = TextureStage('ts_front')
            ts_front.setTexcoordName('front')
            card_np.setTexture(ts_front, self.textures_front[uuid])

        if self.textures_back[uuid] is not None:
            ts_back = TextureStage('ts_back')
            ts_back.setTexcoordName('back')
            card_np.setTexture(ts_back, self.textures_back[uuid])

        return card_np

and here is the code I execute to run the environment:

from pandac.PandaModules import *
from direct.directbase.DirectStart import *
from direct.showbase.DirectObject import DirectObject
from direct.interval.IntervalGlobal import *
from direct.task import Task
import random, sys

class World(DirectObject):
    def __init__(self):
        base.disableMouse()

        self.directionalLight = DirectionalLight('directionalLight')
        self.directionalLightNP = camera.attachNewNode(self.directionalLight)
        self.directionalLightNP.setHpr(20., -20., 0.)
        render.setLight(self.directionalLightNP)

        self.ambientLight = AmbientLight('ambientLight')
        self.ambientLight.setColor(VBase4(0.5, 0.5, 0.5, 0.5))
        self.ambientLightNP = render.attachNewNode(self.ambientLight)
        render.setLight(self.ambientLightNP)

        camera.setPos(0, -50, -50)
        camera.lookAt(0, 0, 0)

        card_np_factory = CardNodePathFactory(
            parent=render,
            dimensions=CardDimensions(),
            textures_front_paths={47: "../resources/images/french_deck/47.png"},
            textures_back_paths="../resources/images/french_deck/back.jpg"
        )

        card = card_np_factory.get_card_node_path(47)
        card.setPos(-3, -15, -25)
        card.lookAt(card, 0, 0.25, 1)

        card2 = card_np_factory.get_card_node_path(47)
        card2.setPos(3, -15, -25)
        card2.lookAt(card2, 0, -0.25, -1)

        self.accept("escape", sys.exit)


if __name__ == '__main__':
    winst = World()

    run()

To test the code, you need to replace the texture paths (textures_front_paths=…; textures_back_paths=…) to some images available on your computer.

Do I have to put the nodes that should receive different textures into different GeomNodes or even into different NodePaths? What else am I doing wrong here?

Hi, welcome to the forums!

Texture stages aren’t the recommended way to apply different textures to different parts of the same model. You have several options:

  1. Set the texture to “border color” mode, with a border color of full white, and when you are writing UVs for the back-side on the front you write values outside the 0-1 range (instead of (0, 0)) so that they sample into the full-white area. Or, set the border color to full transparent and set the texture stage to M_decal mode. I don’t personally recommend this route, though. It will probably result in weird behaviour at the borders unless you choose the values well, and it’s not the most efficient.
  2. Create a separate Geom for each side, adding them to the same GeomNode but with a different RenderState. You can generate a RenderState containing a texture of choice by setting the texture on a dummy NodePath and then calling .getState() on that, or by something like RenderState.make(TextureAttrib.make(texture))
  3. It would be even more efficient to merge the textures for the different sides into a big “atlas” texture and have the UV coordinates point to the respective area in the texture.
  4. If you plan on using custom shaders for your object, you can put your textures in an array texture and write a custom vertex column containing a “texture index” (which indexes into that array) into the vertex data, which would be read out by the shader.

Thanks for your suggestions, rdb! :slight_smile:

I opted to go for #3 as this seems like the best compromise between elegance and ease of implementation.

In case others are interested, the following updated version of the CardNodePathFactory code does what I expect. It uses scikit-image to read the texture images from disk, adds alpha channels (filled with ones) if required and opencv-python to resize the back image to the same size as the front image. Then both images are concatenated into one image using Numpy and finally the resutling array is written to a texture using the Texture.setRamImageAs function.

As suggested by rdb, the UV coordinates are adjusted such that the front samples the left half of the texture and the back the right half.

# Standard imports
from typing import Dict, Union

# External imports
import cv2
import numpy as np
from panda3d.core import *
import skimage.io


class CardDimensions:
    def __init__(self, width=6.35, height=8.89, thickness=0.2):
        self.width = width
        self.height = height
        self.thickness = thickness


class CardNodePathFactory:
    def __init__(self,
                 parent,
                 dimensions: CardDimensions,
                 textures_front_paths: Dict[int, str],
                 textures_back_paths: Union[str, Dict[int, str]]):
        assert isinstance(textures_back_paths, str) or textures_front_paths.keys() == textures_back_paths.keys()

        self.parent = parent
        self.dimensions = dimensions

        # generate texture atlases
        self.textures = {}
        if isinstance(textures_back_paths, str):
            back_img = skimage.io.imread(textures_back_paths)
            back_img = self.add_alpha_channel_if_not_exists(back_img)
        else:
            back_img = None

        for uuid, tex_front_path in textures_front_paths.items():
            front_img = skimage.io.imread(tex_front_path)
            front_img = self.add_alpha_channel_if_not_exists(front_img)
            if back_img is None:
                back_img = skimage.io.imread(textures_back_paths[uuid])
            else:
                back_img = back_img.copy()

            # resize back_img to match front_img shape
            back_img = cv2.resize(back_img,
                                  dsize=(front_img.shape[1], front_img.shape[0]),
                                  interpolation=cv2.INTER_CUBIC)

            combined_img = np.concatenate([front_img, back_img], axis=1).astype(np.uint8)

            tex = Texture('card_texture')
            tex.setup2dTexture(combined_img.shape[1], combined_img.shape[0], Texture.T_unsigned_byte, Texture.F_rgba8)
            tex.setRamImageAs(combined_img, "RGBA")

            self.textures[uuid] = tex

    def get_card_node_path(self, uuid: int) -> NodePath:
        dimensions = [self.dimensions.width / 2, self.dimensions.thickness / 2, self.dimensions.height / 2]

        vertex_format = GeomVertexFormat().getV3n3cpt2()
        vertex_data = GeomVertexData("vertex_data", vertex_format, Geom.UHStatic)

        vertexCount = 0

        tris = GeomTriangles(Geom.UHStatic)

        vertex_writer = GeomVertexWriter(vertex_data, "vertex")
        color_writer = GeomVertexWriter(vertex_data, "color")
        normal_writer = GeomVertexWriter(vertex_data, "normal")
        texcoord_writer = GeomVertexWriter(vertex_data, "texcoord")

        for i in range(3):
            for direction in (-1, 1):
                normal = VBase3()
                normal[i] = direction

                if i == 1:
                    rgb = [1., 1., 1.]
                else:
                    rgb = [0.5, 0.5, 0.5]

                r, g, b = rgb
                color = (r, g, b, 1.)

                for a, b in ((-1., -1.), (-1., 1.), (1., 1.), (1., -1.)):
                    vertex = VBase3()

                    vertex[i] = direction * dimensions[i]
                    vertex[(i + direction) % 3] = a * dimensions[(i + direction) % 3]
                    vertex[(i + direction * 2) % 3] = b * dimensions[(i + direction * 2) % 3]

                    vertex_writer.addData3f(vertex)
                    color_writer.addData4f(color)
                    normal_writer.addData3f(normal)

                    if i == 1:
                        if direction == -1:
                            texcoord_writer.addData2f((a + 1) / 4, (1 - b) / 2)
                        else:
                            texcoord_writer.addData2f(0.5 + (b + 1) / 4, (1 - a) / 2)
                    else:
                        texcoord_writer.addData2f(0, 0)

                vertexCount += 4

                tris.addVertices(vertexCount - 2, vertexCount - 3, vertexCount - 4)
                tris.addVertices(vertexCount - 4, vertexCount - 1, vertexCount - 2)

        geom = Geom(vertex_data)
        geom.addPrimitive(tris)

        node = GeomNode("geom_node")
        node.addGeom(geom)

        card_np = self.parent.attachNewNode(node)
        card_np.setPos(0, 0, 0)

        # set textures
        if self.textures.get(uuid) is not None:
            card_np.setTexture(self.textures[uuid])

        return card_np

    def add_alpha_channel_if_not_exists(self, img: np.ndarray) -> np.ndarray:
        if img.shape[2] == 4:
            return img
        else:
            return np.concatenate([img, np.ones(list(img.shape[:2]) + [1])], axis=2)

Best
Niklas