I’ve got a large sphere with inverted normals that my player is standing inside to simulate a pseudo skybox. I’m trying to click an object in scene (a cube) to change the material on this sphere to create the next “step” in the sequence. I’ve got it working with a starting texture, and then clicking my cube loads the second texture. But I have potentially dozens of textures, and each time the user clicks a cube to advance, both the cube will move and the camera will need to rotate to prepare for the next step. What’s a good way for me to continue and scale this? I’m looking for advice.
I’m hoping this is helpful to other people too because a lot of the solutions I’m seeing online are deprecated.
I’m new to Unity but not to programming, and because I’m not yet familiar enough with Unity’s capabilities I want to write efficient code. The way it’s working now, I have a serialized field where I can drop my single Texture into. I’m trying to accomplish three things:
Can I make an array of these textures and just toggle the array element I need depending on which cube is clicked?
Can I have an array of cubes too, each at their own positions, and hide and show them as needed? Or do I keep one cube but move it around and select which texture it corresponds with?
How can I orient the default rotation of the camera when each new texture loads, or else how can I orient the location of the texture relative to the cameras POV?
I want to be smart about how I architect this and I’m welcoming all advice from the community. Here’s what I’ve got attached to my ClickHotspot cube:
Cache the renderer of the sphere in a variable.
Use the counter integer as the index for the textures array/list. Will need to add or subtract a little to avoid errors and make the number match up.
Thanks for the response, Laperen. Are you able to go into a little more detail about what you mean? Perhaps with a code or pseudocode example? I’m not sure which methods I need, and I’m not sure how to achieve the functionality you’re talking about within Unity.
OK I see, thanks for showing me that. Is your reason for using private Renderer sphereRenderer; and then fetching it with a sphereRenderer = sphere.GetComponent<Renderer>(); less expensive than Find? I’d like to know if this is true because there’s outdated info out there on the internet.
One issue I see with the counter++ approach is that I can only advance forward. Let’s say the player wishes to turn around, and click a cube behind them to return to the previous texture… I’m not sure what to do then.
Also, a big question for me is still how to write a statement that sets the camera and/or texture rotation whenever the texture advances. This is to make sure the horizon stays in place and the path is always in front of the character.
Additionally, what is a statement I could write to move the cube to its new position “on the path”, or is it better to make many cubes and just hide/show them? Any tips? Wondering what you would all do if you were solving this problem.
Caching the renderer is much better than looking it up every time. It’s also much better to use GetComponent than Find, for sure. In general, you should do your best to avoid Find whenever possible Use sparingly until you get comfortable & familiar with the options you’ll learn (such as in this thread).
As for what if they want to go backwards… well, you’d have to subtract? and check that the index >= 0.
OK, thank you! This is all very helpful! I will spend some time adopting this strategy.
I notice when I try to implement:
Camera.main.transform.rotation = Quaternion.Euler(180,180,180); in my switch statement (I did not refactor it yet as per the above suggestions!) the line above has no effect. I also notice that at runtime I can’t change the camera’s rotation in the inspector.
Am I not allowed to change camera rotation via script while the game is running? Or is there some logic in the default asset “FirstPersonCharacter” that prevents me from overriding it with my own camera rotation?
You said the player can turn around and click a previous cube, doesn’t that imply you have many cubes? Where does the clicked cube move to?
If that is the case then this is not a material swap based on number of times clicked, so this logic isn’t right for your needs.
I think what you’re saying is that you have one cube per material essentially, the cubes just act as buttons. So instead of keeping an array of materials in one script, you can give each cube the material reference for that step, and when you click that cube, that material is shown.
So each cube would get the same component with this code in it:
public Texture texture; // the texture that should show when this cube is clicked
// assign this in the inspector
// or give the sphere a unique component and use
// FindObjectOfType<UniqueComponent>() on start to assign it
public Renderer sphereRenderer;
private void OnMouseDown()
{
sphereRenderer.material.mainTexture = texture;
}
OK, it didn’t work for me so I assumed the method was outdated (because I believe a lot of the info available online predates Unity 5 and I assume to be outdated). I realize it’s possible that the above method works fine, just not the way I used it. When I swapped it out for the second method it just worked so I went with it.
So, let me clarify: as I’ve been building this out, Ive got 4 cubes (click triggers). I’ve labeled them cardinally: NCube, WCube, ECube, SCube (or using a WASD convention). Each of these cubes sits roughly at cardinal positions relative to the player: in front of them, behind them, to their left and right.
Surrounding the player is a single large sphere with a Material on it. When a cube is clicked, the texture on the material changes to the next one. I took photos every 10 steps so that I could simulate “walking forward” when a person advances to the next texture. What I’m trying to do now, is that when a person clicks forward, the texture on the sphere changes to a specific index in the array (based on the corresponding image in the sequence), if they click the West cube they would trigger the texture that corresponds with that direction, etc. At every step (texture rendered), they might not be able to move forward, backward, left and right, so I hide the cubes as needed to prevent them from moving that way.
Here’s my code so far (again, I’m still sticking with the switch case so I can handle 10 textures and get my directions working, and then I’ll introduce more later. I’m still using Find because it works… I’ll refactor later).
This script is attached to all 4 of my trigger cubes: WCube, ACube, SCube, DCube:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ClickHotspot : MonoBehaviour {
[SerializeField] private Color colorChange;
[SerializeField] private List<Texture> textures = new List<Texture>();
// [SerializeField] private Texture texture;
[SerializeField] private GameObject sphere;
private GameObject WCube, ACube, SCube, DCube;
int counter = 0;
void Start() {
sphere = GameObject.Find("Sphere");
WCube = GameObject.Find("WCube");
ACube = GameObject.Find("ACube");
SCube = GameObject.Find("SCube");
DCube = GameObject.Find("DCube");
showCubes(true, false, false, false);
sphere.transform.eulerAngles = new Vector3 (2.37f, 3.81f, -4.29f);
counter++;
Debug.Log(counter-1);
}
void OnMouseEnter() {
gameObject.GetComponent<MeshRenderer>().material.color = colorChange;
}
void OnMouseExit() {
gameObject.GetComponent<MeshRenderer>().material.color = Color.white;
}
void OnMouseDown() {
clickCounter();
}
void showCubes(bool w, bool a, bool s, bool d) {
if (!w) WCube.GetComponent<Renderer>().enabled = false;
if (!a) ACube.GetComponent<Renderer>().enabled = false;
if (!s) SCube.GetComponent<Renderer>().enabled = false;
if (!d) DCube.GetComponent<Renderer>().enabled = false;
if (w) WCube.GetComponent<Renderer>().enabled = true;
if (a) ACube.GetComponent<Renderer>().enabled = true;
if (s) SCube.GetComponent<Renderer>().enabled = true;
if (d) DCube.GetComponent<Renderer>().enabled = true;
}
// The walkForward function rotates the sphere so that the texture's horizon
// and rotation stays consistent from frame to frame
void walkForward(float x, float y, float z) {
sphere.transform.eulerAngles = new Vector3 (x, y, z);
}
void clickCounter() {
switch (counter) {
case 1:
sphere.GetComponent<Renderer>().material.mainTexture = textures[0];
walkForward(2.13f, -0.07f, -2.14f);
showCubes(true, false, true, false);
DCube.transform.position = new Vector3 (-2.24f, -0.7f, -9.53f);
SCube.transform.position = new Vector3 (-10.34f, -0.56f, 0.95f);
counter++;
break;
case 2:
sphere.GetComponent<Renderer>().material.mainTexture = textures[1];
walkForward(2.13f, -2.63f, -3.39f);
counter++;
break;
case 3:
sphere.GetComponent<Renderer>().material.mainTexture = textures[2];
walkForward(-1.12f, -4.91f, -3.34f);
counter++;
break;
case 4:
sphere.GetComponent<Renderer>().material.mainTexture = textures[3];
walkForward(1.4f, -9.51f, -5.62f);
showCubes(true, false, true, false);
// It breaks when you do this:
// showCubes(false, false, true, false);
DCube.transform.position = new Vector3 (-2.24f, -0.7f, -9.53f);
counter++;
break;
case 5:
sphere.GetComponent<Renderer>().material.mainTexture = textures[4];
walkForward(7.07f, 77.5f, -4.42f);
counter++;
break;
case 6:
sphere.GetComponent<Renderer>().material.mainTexture = textures[5];
walkForward(-5.13f, 50.64f, -2.37f);
counter++;
break;
case 7:
sphere.GetComponent<Renderer>().material.mainTexture = textures[6];
walkForward(9.98f, 112.5f, -9.8f);
counter++;
break;
case 8:
sphere.GetComponent<Renderer>().material.mainTexture = textures[7];
walkForward(8.47f, 101.17f, 0.69f);
counter++;
break;
case 9:
sphere.GetComponent<Renderer>().material.mainTexture = textures[8];
walkForward(3.98f, 112.5f, -4.54f);
counter++;
break;
default:
break;
}
Debug.Log(counter-1);
}
}
Two problems with the above:
Introducing multiple cubes, all with a counter++ incrementer, makes it so that any cube advances the count. I don’t just want to move forward, I want to be able to trigger the material that simulates movement right or left (see the attached photos, they are in sequence Step 1, 2, and 3). I.E. if I am rendering texture[5] and I click the “Back” cube, I should go to texture[4]. If I am rendering texture[5] and I click the “Left” cube, perhaps the corresponding texture in the array is texture[11] and we jump to that index. Also, because this same script is attached to all 4 cubes I think it’s getting confused and I’m not sure how to distinguish between them.
Around line 81 it breaks. When I try to hide/show cubes, it loops me back to the first texture in the array and I start at the beginning of my sequence. No idea why.
This is an interesting problem, and I’m having fun solving it. Tips and suggestions are still very much appreciated
Okay, I have a much better idea of what you’re trying to do, but let me ask you another question.
You said that if you keep clicking forward, you might wind up at texture index 5. Then if you click Left, it might take you to texture index 11. If you didn’t click Left, and kept clicking forward, would you end up at texture 11?
Right now you’re approaching this as a linear list of textures, but it sounds like you want more of a 2D list of textures that you can navigate through with an X index and a Y index, is that accurate?
Let me clarify: I start the demo, there’s a cube in front of me (and none behind me, because there is no image for a player to navigate to as we are in the first texture. I click the cube in front of me once, and I’m in texture 2. Now a cube appears behind me because a person CAN go back to the previous. I continue forward to 3, 4 and 5. Now, because in real life I did not take a photo any further forward than this, the user cannot go further in this direction. So the cube in front of me disappears, and new cubes appear to my left and right. In real life, I turn left and take 5 photos in that direction. So at the end of this path we are at photo 10. Now, because the player could have also gone right at that crossroads I mentioned, in real life, I return to this spot, take that right, and take my photo (which is 11). So, from that juncture point (photo 5), to a player’s left is photo 6 but to their right is photo 11. Does that make sense?
That’s probably right. A matrix might serve well here, but I’m nowhere near that point in my Unity journey to know how to do that. Basically, I want to build a system (eventually) where I can import all my textures, place the appropriate cubes or trigger points, and allow someone to take a 360 degree tour of the environment.
Well I had some free time over my lunch break so I decided to try and whip up something that might work for you.
Basically I made a class that allows you to set up your steps in terms of 2D positions, which then get converted to an internal 2D array. Each step consists of a Vector2 location (integers only for index), and a Texture. You should be able to expand on this if you want more functionality when changing steps.
I added some public functions like “MoveBy” and “MoveTo” and “IsValidLocation” that you can hook your cubes up to.
If it all works the way I’m imagining, your North cube will always call “MoveBy(0,1)”, the East cube will call “MoveBy(1,0)”, and so on. For cubes that lead nowhere and should be hidden, you can use “IsValidLocation” to see if there’s anything in the next step. If not, hide the cube. If there is, show the cube.
I admit I got a little carried away with it, but it was fun. Try it out and let me know if it makes sense to you.
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class NavigationMap : MonoBehaviour
{
// data container for each step
[Serializable] public class Step
{
[HideInInspector] public string inspectorName;
public bool startingLocation;
public Vector2 location;
public Texture skyTexture;
}
#region Inspector
// reference to SkySphere mesh, drag in or find on Awake
[SerializeField] private MeshRenderer skySphere;
// setup the map in the inspector
[SerializeField] private List<Step> map;
#endregion Inspector
// Steps get converted to 2D array for easier use internally
public Step[,] Map { get; private set; }
public Step CurrentStep { get; private set; }
public int CurrentX { get; private set; }
public int CurrentY { get; private set; }
private void Awake()
{
if(Map == null)
{
SetupMap();
}
// start at the starting location, or 0,0
Step startStep = map.Find(step => step.startingLocation);
if(startStep == null) {
startStep = Map[0, 0];
}
SetStep(startStep);
}
// moves by offset if valid location
public bool MoveBy(int x, int y)
{
int newX = (int)CurrentStep.location.x + x;
int newY = (int)CurrentStep.location.y + y;
return MoveTo(newX, newY);
}
// moves directly to new position if valid location
public bool MoveTo(int x, int y)
{
if(IsValidLocation(x, x))
{
SetStep(Map[x, x]);
return true;
}
return false;
}
// check if a location is within the map dimensions
public bool IsValidLocation(int x, int y) {
bool validX = x > 0 && x < Map.GetLength(0);
bool validY = y > 0 && y < Map.GetLength(1);
return validX && validY && Map[x, y] != null;
}
// function to do whatever needs to be done on step change
private void SetStep(Step newStep)
{
CurrentStep = newStep;
CurrentX = Mathf.RoundToInt(newStep.location.x);
CurrentY = Mathf.RoundToInt(newStep.location.y);
if(skySphere != null && CurrentStep.skyTexture != null) {
skySphere.material.mainTexture = CurrentStep.skyTexture;
}
}
// build the map from the inspector values
private void SetupMap()
{
// get map dimensions by finding the maximum X and Y locations
int rows = Mathf.RoundToInt(map.Max(step => step.location.x)) + 1;
int columns = Mathf.RoundToInt(map.Max(step => step.location.y)) + 1;
if(map.Count == 0)
{
return;
}
// create Map 2D array of Steps
Map = new Step[rows, columns];
// loop through each entry in the inspector
foreach(Step step in map)
{
// keep locations positive and round to ints
int x = Mathf.Max(0, Mathf.RoundToInt(step.location.x));
int y = Mathf.Max(0, Mathf.RoundToInt(step.location.y));
step.location.x = x;
step.location.y = y;
// check if the location already exists
Step stepAtLocation = Map[x, y];
if(stepAtLocation == null)
{
Map[x, y] = step;
}
else
{
step.inspectorName = "[DUPLICATE] " + step.inspectorName;
Debug.LogFormat("Duplicate Entry at ({0},{1}).", x, y);
}
}
}
// called automatically any time the inspector changes
private void OnValidate()
{
int startingIndex = -1;
// assign names to the inspector entries
for(int i = 0; i < map.Count; i++)
{
Step step = map[i];
// if no start step has been encountered yet
// set this one as the start
if(step.startingLocation)
{
if(startingIndex == -1)
{
startingIndex = i;
}
else
{
step.startingLocation = false;
}
}
string prefix = step.startingLocation ? "[START] " : string.Empty;
step.inspectorName = prefix + step.location.ToString();
}
// build the
SetupMap();
}
}
If you add that script to an empty gameobject, it should just work. Think of it as a big data container and grid manager of sorts. You can do a FindObjectOfType() to get a reference to it in other scripts on Awake.
Hopefully there are no bugs, and feel free to ask questions if anything doesn’t make sense to you.