// Copyright (c) 2025 The Foundry Visionmongers Ltd. All Rights Reserved. #include "DDImage/DDWindows.h" #include "DDImage/Material.h" #include "DDImage/VertexContext.h" #include "DDImage/CameraOp.h" #include "DDImage/Knobs.h" #include "DDImage/ViewerContext.h" #include "DDImage/gl.h" #include "DDImage/GeoInfo.h" #include "DDImage/Scene.h" #include "DDImage/OpTimer.h" #include "DDImage/Render.h" using namespace DD::Image; namespace Foundry { static const char* const kHelp = "Outputs a shader material that projects the input image through " "the input camera onto the 3D object. Use 3D/shader/apply to put " "the output of this operator onto a 3D object. " "Enable the raycasting feature on the ScanlineRender node to enable occlusion testing. "; const char* const kSurfaceNames[] = { "both", "front", "back", nullptr }; const char* const kCastOcclusionNames[] = { "none", "self", "world", nullptr }; enum CastOcclusion { eCastOcclusionNone = 0, // disable cast projection eCastOcclusionSelf, // perform a cast projection only with geometries that use the same material eCastOcclusionWorld, // cast with the entire world }; } using namespace Foundry; class Project3D : public Material { protected: Matrix4 _projectXform; int _surface = 0; bool _crop = true; int _occlusionMode = eCastOcclusionNone; void _validate(bool for_real) override { Material::_validate(for_real); copy_info(); // put the format & bounding box back! _projectXform.translation(0.5f, 0.5f, 0.0f); _projectXform.scale(0.5f, 0.5f * static_cast(format().w()) * static_cast(format().pixel_aspect()) / static_cast(format().h()), 0.5f); // Check if input 1 is connected and get the camera xform from it: CameraOp* cam = op_cast(Op::input(1)); if (cam) { cam->validate(for_real); _projectXform *= cam->projection(); _projectXform *= cam->imatrix(); } } public: const char* node_help() const override { return kHelp; } int minimum_inputs() const override { return 2; } int maximum_inputs() const override { return 2; } Project3D(Node* node) : Material(node) {} bool test_input(int input, Op* op) const override { if (input == 1) { return op_cast(op) != nullptr; } return Material::test_input(input, op); } Op* default_input(int input) const override { if (input == 0) { return Iop::default_input(input); } return nullptr; } const char* input_label(int input, char* buffer) const override { switch (input) { case 0: return ""; case 1: return "cam"; } return buffer; } void knobs(Knob_Callback f) override { Obsolete_knob(f, "projection", nullptr); Enumeration_knob(f, &_surface, kSurfaceNames, "project_on", "project on"); Bool_knob(f, &_crop, "crop"); Tooltip(f, "Crop the incoming image, putting black outside the format area."); Enumeration_knob(f, &_occlusionMode, kCastOcclusionNames, "occlusion_mode", "occlusion mode"); Tooltip(f, "Select the ray casting projection occlusion test mode:" "
  • none - the occlusion test is disabled.
  • " "
  • self - only the geometry connected to this shader can occlude the projection.
  • " "
  • world - other geometries in the scene can occlude the projection.
  • " "
" ); Obsolete_knob(f, "outside", "knob crop false"); Obsolete_knob(f, "Project outside Camera?", "knob crop false"); } /*! Hash up knobs that affect the point attributes. */ void get_geometry_hash(Hash* geo_hash) override { // Force the material to be reevaluated lower in the // tree using a random number to twiddle the hash: Material* m = dynamic_cast(input(0)); if (m) { m->get_geometry_hash(geo_hash); } static int x; geo_hash[Group_Object].append(x); } void setGLPlane(GLenum plane, const Vector3& n, const Vector3& p) { // Set up an OpenGL plane passing for a point with a particular normal double eq[4] = { n.x, n.y, n.z, -n.dot(p) }; glClipPlane(plane, eq); glEnable(plane); } bool shade_GL(ViewerContext* ctx, GeoInfo& geo) override { // Reproduce the black_outside effect on the texture if crop is on: if (_crop) { #ifndef GL_CLAMP_TO_BORDER #define GL_CLAMP_TO_BORDER 0x812d #endif glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); } glColor4f(1, 1, 1, 1); CameraOp* cam = op_cast(Op::input(1)); // Let's try the clipping plane to get rid of stuff behind projector if (cam) { // Save model view matrix glPushMatrix(); Matrix4 m = ctx->cam_matrix() * cam->matrix(); glLoadMatrixf(m.array()); Vector3 n(0, 0, 1); if (_crop) { setGLPlane(GL_CLIP_PLANE1, -n, Vector3(0.0f, 0.0f, -static_cast(cam->Near()))); setGLPlane(GL_CLIP_PLANE2, n, Vector3(0.0f, 0.0f, -static_cast(cam->Far()))); } else{ setGLPlane(GL_CLIP_PLANE0, -n, Vector3(0, 0, 0)); } if (_surface) { // Be sure to remove the local transformation glLoadMatrixf(ctx->cam_matrix().array()); const Matrix4& m = cam->matrix(); glPushAttrib(GL_LIGHTING_BIT | GL_TRANSFORM_BIT | GL_COLOR_BUFFER_BIT); glDisable(GL_COLOR_MATERIAL); glLightfv(GL_LIGHT0, GL_POSITION, &(m.a03)); Vector4 t; // turn off the default 1.0 alphas so unlit areas are transparent: t.set(0, 0, 0, 0); glLightModelfv(GL_LIGHT_MODEL_AMBIENT, t.array()); glLightfv(GL_LIGHT0, GL_AMBIENT, t.array()); glLightfv(GL_LIGHT0, GL_SPECULAR, t.array()); glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE, t.array()); glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, t.array()); glMaterialfv(GL_FRONT_AND_BACK, GL_EMISSION, t.array()); // Now turn on all the visible stuff: t.set(1, 1, 1, 1); glLightfv(GL_LIGHT0, GL_DIFFUSE, t.array()); glLightfv(GL_LIGHT0, GL_SPOT_DIRECTION, (-m.z_axis()).array()); glLightf(GL_LIGHT0, GL_SPOT_CUTOFF, 90); glLightf(GL_LIGHT0, GL_CONSTANT_ATTENUATION, 1.0f); glLightf(GL_LIGHT0, GL_LINEAR_ATTENUATION, 0.0f); glLightf(GL_LIGHT0, GL_QUADRATIC_ATTENUATION, 0.0f); t.set(1, 1, 1, 1); glMaterialfv(_surface == 1 ? GL_FRONT : GL_BACK, GL_DIFFUSE, t.array()); glLightModeli(GL_LIGHT_MODEL_TWO_SIDE, GL_TRUE); glEnable(GL_LIGHT0); glEnable(GL_LIGHTING); } // restore model view matrix glPopMatrix(); } static GLfloat xplane[4] = { 1, 0, 0, 0 }; static GLfloat yplane[4] = { 0, 1, 0, 0 }; static GLfloat zplane[4] = { 0, 0, 1, 0 }; glMatrixMode(GL_TEXTURE); glMultMatrixf(_projectXform.array()); glMultMatrixf(geo.matrix.array()); ctx->non_default_texture_matrix(true); glMatrixMode(GL_MODELVIEW); glTexGeni(GL_S, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR); glTexGenfv(GL_S, GL_OBJECT_PLANE, xplane); glEnable(GL_TEXTURE_GEN_S); glTexGeni(GL_T, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR); glTexGenfv(GL_T, GL_OBJECT_PLANE, yplane); glEnable(GL_TEXTURE_GEN_T); glTexGeni(GL_R, GL_TEXTURE_GEN_MODE, GL_OBJECT_LINEAR); glTexGenfv(GL_R, GL_OBJECT_PLANE, zplane); glEnable(GL_TEXTURE_GEN_R); return true; } void unset_texturemap(ViewerContext* ctx) override { if (Op::input(1) && _surface) { glPopAttrib(); } glDisable(GL_CLIP_PLANE0); glDisable(GL_CLIP_PLANE1); glDisable(GL_CLIP_PLANE2); glDisable(GL_TEXTURE_GEN_S); glDisable(GL_TEXTURE_GEN_T); glDisable(GL_TEXTURE_GEN_R); input0().unset_texturemap(ctx); } // Used to replicate OpenGL output when transparency is switched off: static void black(const VertexContext& vtx, Pixel& out) { if (!vtx.scene()->transparency()) { for (const auto z : out.channels) { out[z] = 0; } out[Chan_Z] = vtx.w(); } } /*! This projects a single vertex and passes the resulting UV up the tree so this face's UV bbox is expanded in Iop::vertex_shader. */ void vertex_shader(VertexContext& vtx) override { // In UV or spherical render mode the entire input UV range is required: Vector4 savedUV(vtx.vP.UV()); bool forceFullFrame = false; if (vtx.scene()->projection_mode() <= CameraOp::LENS_ORTHOGRAPHIC) { // Project the world-space point backwards into projector: Vector4 UV = _projectXform.transform(vtx.PW(), vtx.w()); // set it only if w is positive, leave the negative one alone because it // will not calculate the uv bbox correctly if (UV.w > 0.0f) { vtx.vP.UV() = UV; } else { forceFullFrame = true; } } // Continue on up the tree: input0().vertex_shader(vtx); if (forceFullFrame) { vtx.face_uv_bbox().expand(Box3(Vector3(0, 0, 0) , Vector3(1, 1, 1))); } // Restore the original UV: vtx.vP.UV() = savedUV; // Make sure N and PW are interpolated for fragment_shader(): vtx.vP.channels += ChannelSetInit(Mask_N_ | Mask_PW_); } void fragment_shader(const VertexContext& vtx, Pixel& out) override { OpTimer iopTimer(this, OpTimer::eEngine); // Project the world-space point backwards into projector: Vector4 UV = _projectXform.transform(vtx.PW(), vtx.w()); if (UV.w < 0) { return; // ignore behind the camera } CameraOp* cam = op_cast(Op::input(1)); if (_surface) { float invW = 1.0f / vtx.w(); Vector3 N = vtx.N() * invW; Vector3 PW = vtx.PW() * invW; // Cull any pixels where normal points wrong to camera // This reproduces the OpenGL preview backface culling. const Matrix4& i = vtx.transforms()->matrix(EYE_TO_WORLD); float z = (i.translation() - PW).dot(N); if ((_surface == 1) == (z < 0)) { black(vtx, out); return; } // Now make sure the normals point at the projector: if (cam) { const Matrix4& i = cam->matrix(); float z = (i.translation() - PW).dot(N); if ((_surface == 1) == (z < 0)) { black(vtx, out); return; } } } if (_crop) { float two_z = 2.0f*UV.z; if (UV.x < 0 || UV.x > UV.w || UV.y < 0 || UV.y > UV.w || // ignore outside the 0..1 box: two_z < -UV.w || two_z > UV.w) { // ignore outside the near and far planes black(vtx, out); return; } } if ((_occlusionMode > eCastOcclusionNone) && cam) { float inv_w = 1.0f / vtx.w(); Ray ray; // Slightly move ray from surface in order to avoid self collision with starting point const float kEpsilon = 1.0e-3f; // Doing all calculation in local coordinate // in order to be sure to avoid self collision // with the starting position of the ray. Vector3 cl = vtx.matrix(WORLD_TO_LOCAL).transform(cam->matrix().translation()); Vector3 pl = vtx.PL() * inv_w; Vector3 dir = cl - pl; dir.normalize(); pl += dir * kEpsilon; // Get the point in world coordinate Vector3 pw = vtx.matrix(LOCAL_TO_WORLD).transform(pl); ray.src = pw; ray.dir = cam->matrix().translation() - ray.src; ray.maxT = ray.dir.normalize(); ray.minT = 0; bool castResult = false; if (_occlusionMode == eCastOcclusionSelf) { castResult = vtx.scene()->testRayIntersection(ray, &vtx, vtx.rmaterial()); } else if(_occlusionMode == eCastOcclusionWorld) { castResult = vtx.scene()->testRayIntersection(ray, &vtx); } if (castResult) { black(vtx, out); return; } } // Make new VertexContext with correct UV: VertexContext v2(vtx); v2.vP.UV() = UV; // We want to compute the derivatives of UV with respect to screen-space X and Y. // UV is computed as a multiplication of _projectXform with the vector combining world // position (PW) and w, which are functions of the screen-space coordinates (X and Y). // Multiplying out the matrix with the vector yields linear combinations of functions // per component of the form: // UV.x = a * PW.x + b * PW.y + c * PW.z + d * w (similar for UV.y etc.) // Since the matrix _projectXform is not dependent on screen-space coordinates, // we can apply linearity of differentiation: // UV.x' = a * PW.x' + b * PW.y' + c * PW.z' + d' * w // i.e. for matrix M and vector of functions V(x): d(M*V(x))/dx = M * dV(x)/dx // Therefore we can compute the derivative of UV by computing the derivatives of PW // and w individually and multiply by _projectXform. v2.vdX.UV() = _projectXform.transform(vtx.dPWdu(), vtx.vdX.w()); v2.vdY.UV() = _projectXform.transform(vtx.dPWdv(), vtx.vdY.w()); // Pass on up, typically to an Iop that samples the region: input0().fragment_shader(v2, out); } static const Description description; const char* Class() const override { return description.name; } HandlesMode doAnyHandles(ViewerContext* ctx) override { CameraOp* cam = op_cast(Op::input(1)); if (cam) { int savedMode = ctx->transform_mode(); ctx->transform_mode(VIEWER_PERSP); Op::HandlesMode any = cam->anyHandles(ctx); ctx->transform_mode(savedMode); return any; } return eNoHandles; } /*! Draws the camera in world space coordinates. */ void build_handles(ViewerContext* ctx) override { // Add the input camera to the Viewer's pulldown list, // and have it draw itself in 3D mode: CameraOp* cam = op_cast(Op::input(1)); if (cam) { ctx->addCamera(cam); Matrix4 savedMatrix(ctx->modelmatrix); int saved_mode = ctx->transform_mode(); ctx->transform_mode(VIEWER_PERSP); ctx->modelmatrix.makeIdentity(); add_input_handle(1, ctx); ctx->modelmatrix = savedMatrix; ctx->transform_mode(saved_mode); } } OpHints opHints() const override { // We can't apply top-down efficiently to GeoOps yet so we'll instead render // them on-demand when downstream ops try to fetch their output. return OpHints::eIgnore | OpHints::eIgnoreInputs; } }; static Op* build(Node* node) { return new Project3D(node); } const Op::Description Project3D::description("Project3D", build);