UnityStandardAsset project, source code analysis_ 4_ Racing game [AI control]_ AI mechanism

Address of the previous chapter: UnityStandardAsset project, source code analysis_ 4_ Racing game [player control]_ Came...

Address of the previous chapter: UnityStandardAsset project, source code analysis_ 4_ Racing game [player control]_ Camera control

In the previous chapters, we have analyzed most of the mechanisms of racing games, and Unity also provides different control modes - AI control. As its name implies, AI is used to control the game instead of players, and the vehicle will automatically circle the field. The scenes controlled by AI are generally similar to those controlled by players, so the repeated part will not be repeated. We will focus on the analysis of AI related mechanisms.

AI control


AI controls the script attached to the vehicle in the scene, which is different from the vehicle controlled by the player. In the screenshot of the first chapter, CarUserControl script is attached to the vehicle to read in player input and pass it to CarController, but CarUserControl is not attached to the vehicle here, instead of CarAIControl and WaypointProgressTracker. At the same time, there are also the following objects in the scene:

WaypointCircuit is mounted on Waypoints:

In addition, in the first chapter, we also observed an object named WaypointTargetObject. At that time, we only briefly mentioned that this object is used for AI related objects. In this chapter, it plays a very important role.

Next, we will analyze the principle of AI driving completely according to the scripts and objects mentioned above. Let's start with CarAIControl:

namespace UnityStandardAssets.Vehicles.Car { [RequireComponent(typeof (CarController))] public class CarAIControl : MonoBehaviour { // Three behavior patterns public enum BrakeCondition { // Keep accelerating, don't slow down NeverBrake, // the car simply accelerates at full throttle all the time. // Decelerate according to the angle between path points TargetDirectionDifference, // the car will brake according to the upcoming change in direction of the target. Useful for route-based AI, slowing for corners. // Slow down as you approach the road TargetDistance, // the car will brake as it approaches its target, regardless of the target's direction. Useful if you want the car to // head for a stationary target and come to rest when it arrives there. } // This script provides input for the vehicle's control, just like the player's input // In this way, it's really "driving" the vehicle without any special physical or animation effects // This script provides input to the car controller in the same way that the user control script does. // As such, it is really 'driving' the car, with no special physics or animation tricks to make the car behave properly. // "Loitering" is used to make vehicles more like human beings operating, rather than machines operating // He can change speed and direction slightly as he drives towards the target // "wandering" is used to give the cars a more human, less robotic feel. They can waver slightly // in speed and direction while driving towards their target. [SerializeField] [Range(0, 1)] private float m_CautiousSpeedFactor = 0.05f; // percentage of max speed to use when being maximally cautious [SerializeField] [Range(0, 180)] private float m_CautiousMaxAngle = 50f; // angle of approaching corner to treat as warranting maximum caution [SerializeField] private float m_CautiousMaxDistance = 100f; // distance at which distance-based cautiousness begins [SerializeField] private float m_CautiousAngularVelocityFactor = 30f; // how cautious the AI should be when considering its own current angular velocity (i.e. easing off acceleration if spinning!) [SerializeField] private float m_SteerSensitivity = 0.05f; // how sensitively the AI uses steering input to turn to the desired direction [SerializeField] private float m_AccelSensitivity = 0.04f; // How sensitively the AI uses the accelerator to reach the current desired speed [SerializeField] private float m_BrakeSensitivity = 1f; // How sensitively the AI uses the brake to reach the current desired speed [SerializeField] private float m_LateralWanderDistance = 3f; // how far the car will wander laterally towards its target [SerializeField] private float m_LateralWanderSpeed = 0.1f; // how fast the lateral wandering will fluctuate [SerializeField] [Range(0, 1)] private float m_AccelWanderAmount = 0.1f; // how much the cars acceleration will wander [SerializeField] private float m_AccelWanderSpeed = 0.1f; // how fast the cars acceleration wandering will fluctuate [SerializeField] private BrakeCondition m_BrakeCondition = BrakeCondition.TargetDistance; // what should the AI consider when accelerating/braking? [SerializeField] private bool m_Driving; // whether the AI is currently actively driving or stopped. [SerializeField] private Transform m_Target; // 'target' the target object to aim for. [SerializeField] private bool m_StopWhenTargetReached; // should we stop driving when we reach the target? [SerializeField] private float m_ReachTargetThreshold = 2; // proximity to target to consider we 'reached' it, and stop driving. private float m_RandomPerlin; // A random value for the car to base its wander on (so that AI cars don't all wander in the same pattern) private CarController m_CarController; // Reference to actual car controller we are controlling private float m_AvoidOtherCarTime; // time until which to avoid the car we recently collided with private float m_AvoidOtherCarSlowdown; // how much to slow down due to colliding with another car, whilst avoiding private float m_AvoidPathOffset; // direction (-1 or 1) in which to offset path to avoid other car, whilst avoiding private Rigidbody m_Rigidbody; private void Awake() { // Get the core logical control of the vehicle // get the car controller reference m_CarController = GetComponent<CarController>(); // Random seeds of vehicle wandering // give the random perlin a random value m_RandomPerlin = Random.value*100; m_Rigidbody = GetComponent<Rigidbody>(); } private void FixedUpdate() { if (m_Target == null || !m_Driving) { // Do not move without driving or target, use the handbrake to stop the vehicle // Car should not be moving, // use handbrake to stop m_CarController.Move(0, 0, -1f, 1f); } else { // Positive direction, if the speed is greater than 10% of the maximum speed, it is the speed direction, otherwise it is the model direction Vector3 fwd = transform.forward; if (m_Rigidbody.velocity.magnitude > m_CarController.MaxSpeed*0.1f) { fwd = m_Rigidbody.velocity; } float desiredSpeed = m_CarController.MaxSpeed; // Now it's time to decide if we should slow down // now it's time to decide if we should be slowing down... switch (m_BrakeCondition) { // Limit speed based on path point angle case BrakeCondition.TargetDirectionDifference: { // the car will brake according to the upcoming change in direction of the target. Useful for route-based AI, slowing for corners. // First, calculate the angle between our current orientation and the orientation of the path point // check out the angle of our target compared to the current direction of the car float approachingCornerAngle = Vector3.Angle(m_Target.forward, fwd); // Consider also the angular velocity we are currently turning // also consider the current amount we're turning, multiplied up and then compared in the same way as an upcoming corner angle float spinningAngle = m_Rigidbody.angularVelocity.magnitude*m_CautiousAngularVelocityFactor; // The bigger the angle, the more caution // if it's different to our current angle, we need to be cautious (i.e. slow down) a certain amount float cautiousnessRequired = Mathf.InverseLerp(0, m_CautiousMaxAngle, Mathf.Max(spinningAngle, approachingCornerAngle)); // To obtain the required speed, the larger the cautiousness required, the smaller the desiredSpeed desiredSpeed = Mathf.Lerp(m_CarController.MaxSpeed, m_CarController.MaxSpeed*m_CautiousSpeedFactor, cautiousnessRequired); break; } // Limit the speed according to the distance of the approach point case BrakeCondition.TargetDistance: { // the car will brake as it approaches its target, regardless of the target's direction. Useful if you want the car to // head for a stationary target and come to rest when it arrives there. // Calculate the distance vector between the target and itself // check out the distance to target Vector3 delta = m_Target.position - transform.position; // Calculate the distance caution factor based on the maximum and current distance float distanceCautiousFactor = Mathf.InverseLerp(m_CautiousMaxDistance, 0, delta.magnitude); // Consider also the angular velocity we are currently turning // also consider the current amount we're turning, multiplied up and then compared in the same way as an upcoming corner angle float spinningAngle = m_Rigidbody.angularVelocity.magnitude*m_CautiousAngularVelocityFactor; // The bigger the angle, the more caution // if it's different to our current angle, we need to be cautious (i.e. slow down) a certain amount float cautiousnessRequired = Mathf.Max( Mathf.InverseLerp(0, m_CautiousMaxAngle, spinningAngle), distanceCautiousFactor); // Get the speed you need, the more careful the desiredSpeed is desiredSpeed = Mathf.Lerp(m_CarController.MaxSpeed, m_CarController.MaxSpeed*m_CautiousSpeedFactor, cautiousnessRequired); break; } // Infinite acceleration mode does not need to be cautious. The desiredSpeed is m_CarController.MaxSpeed , i.e. no reduction case BrakeCondition.NeverBrake: break; } // Evasive action in collision with other vehicles // Evasive action due to collision with other cars: // Target offset coordinates start from the real target coordinates // our target position starts off as the 'real' target position Vector3 offsetTargetPos = m_Target.position; // If we're taking evasive action to avoid getting stuck with other cars // if are we currently taking evasive action to prevent being stuck against another car: if (Time.time < m_AvoidOtherCarTime) { // Slow down if necessary (we're in the back of other cars when there's a collision) // slow down if necessary (if we were behind the other car when collision occured) desiredSpeed *= m_AvoidOtherCarSlowdown; // Turn to another direction // and veer towards the side of our path-to-target that is away from the other car offsetTargetPos += m_Target.right*m_AvoidPathOffset; } else { // Without taking evasive actions, we can stroll along the path at random to avoid that the AI looks too rigid when driving the vehicle // no need for evasive action, we can just wander across the path-to-target in a random way, // which can help prevent AI from seeming too uniform and robotic in their driving offsetTargetPos += m_Target.right* (Mathf.PerlinNoise(Time.time*m_LateralWanderSpeed, m_RandomPerlin)*2 - 1)* m_LateralWanderDistance; } // Use different sensitivity, depending on acceleration or deceleration // use different sensitivity depending on whether accelerating or braking: float accelBrakeSensitivity = (desiredSpeed < m_CarController.CurrentSpeed) ? m_BrakeSensitivity : m_AccelSensitivity; // Determine the true acceleration / deceleration input according to the sensitivity, clamp to [- 1,1] // decide the actual amount of accel/brake input to achieve desired speed. float accel = Mathf.Clamp((desiredSpeed - m_CarController.CurrentSpeed)*accelBrakeSensitivity, -1, 1); // Using Berlin noise to randomize acceleration to make AI more human like, but I don't quite understand why // add acceleration 'wander', which also prevents AI from seeming too uniform and robotic in their driving // i.e. increasing the accel wander amount can introduce jostling and bumps between AI cars in a race accel *= (1 - m_AccelWanderAmount) + (Mathf.PerlinNoise(Time.time*m_AccelWanderSpeed, m_RandomPerlin)*m_AccelWanderAmount); // Convert the target coordinates of the previously calculated offset to local coordinates // calculate the local-relative position of the target, to steer towards Vector3 localTarget = transform.InverseTransformPoint(offsetTargetPos); // Calculate the local target angle around the y-axis // work out the local angle towards the target float targetAngle = Mathf.Atan2(localTarget.x, localTarget.z)*Mathf.Rad2Deg; // Get the angle you need to turn to the target // get the amount of steering needed to aim the car towards the target float steer = Mathf.Clamp(targetAngle*m_SteerSensitivity, -1, 1)*Mathf.Sign(m_CarController.CurrentSpeed); // Use this data to call the Move method // feed input to the car controller. m_CarController.Move(steer, accel, accel, 0f); // If too close to the target, stop driving // if appropriate, stop driving when we're close enough to the target. if (m_StopWhenTargetReached && localTarget.magnitude < m_ReachTargetThreshold) { m_Driving = false; } } } private void OnCollisionStay(Collision col) { // Detect collisions with other vehicles and take avoidance actions for this // detect collision against other cars, so that we can take evasive action if (col.rigidbody != null) { var otherAI = col.rigidbody.GetComponent<CarAIControl>(); // CarAIControl should also be attached to the collision object, otherwise no action will be taken if (otherAI != null) { // We'll take evasive action in a second // we'll take evasive action for 1 second m_AvoidOtherCarTime = Time.time + 1; // So who's ahead? // but who's in front?... if (Vector3.Angle(transform.forward, otherAI.transform.position - transform.position) < 90) { // The other side is in front, we need to slow down // the other ai is in front, so it is only good manners that we ought to brake... m_AvoidOtherCarSlowdown = 0.5f; } else { // We're in front, there's no need to slow down // we're in front! ain't slowing down for anybody... m_AvoidOtherCarSlowdown = 1; } // Both vehicles need to take evasive actions to drive away from the target and away from each other // both cars should take evasive action by driving along an offset from the path centre, // away from the other car var otherCarLocalDelta = transform.InverseTransformPoint(otherAI.transform.position); float otherCarAngle = Mathf.Atan2(otherCarLocalDelta.x, otherCarLocalDelta.z); m_AvoidPathOffset = m_LateralWanderDistance*-Mathf.Sign(otherCarAngle); } } } public void SetTarget(Transform target) { m_Target = target; m_Driving = true; } } }

The comments written by Unity itself are also very detailed, which are totally different from other scripts, and may not be made by the same developer.
It can be seen that the main function of this script is based on its own state (speed, angular speed, driving mode), as well as the very important m_ The target object and other data call the Move method to update the vehicle status. As for the implementation of the algorithm, Unity and my comments have been written clearly. Now the main problem is that M_ What is target? From the above algorithm, we can see that our vehicle has been "catching up" with this m_Target, it can't be stationary all the time, otherwise the vehicle won't start, so how does it Move? The answer to this question lies in waypoint progress Tracker:

namespace UnityStandardAssets.Utility { public class WaypointProgressTracker : MonoBehaviour { // This script is applicable to any object that wants to follow a series of path points // This script can be used with any object that is supposed to follow a // route marked out by waypoints. // How many forward looking does this script manage? (management path point) // This script manages the amount to look ahead along the route, // and keeps track of progress and laps. [SerializeField] private WaypointCircuit circuit; // A reference to the waypoint-based route we should follow [SerializeField] private float lookAheadForTargetOffset = 5; // The offset ahead along the route that the we will aim for [SerializeField] private float lookAheadForTargetFactor = .1f; // A multiplier adding distance ahead along the route to aim for, based on current speed [SerializeField] private float lookAheadForSpeedOffset = 10; // The offset ahead only the route for speed adjustments (applied as the rotation of the waypoint target transform) [SerializeField] private float lookAheadForSpeedFactor = .2f; // A multiplier adding distance ahead along the route for speed adjustments [SerializeField] private ProgressStyle progressStyle = ProgressStyle.SmoothAlongRoute; // whether to update the position smoothly along the route (good for curved paths) or just when we reach each waypoint. [SerializeField] private float pointToPointThreshold = 4; // proximity to waypoint which must be reached to switch target to next waypoint : only used in PointToPoint mode. public enum ProgressStyle { SmoothAlongRoute, PointToPoint, } // these are public, readable by other objects - i.e. for an AI to know where to head! public WaypointCircuit.RoutePoint targetPoint { get; private set; } public WaypointCircuit.RoutePoint speedPoint { get; private set; } public WaypointCircuit.RoutePoint progressPoint { get; private set; } public Transform target; private float progressDistance; // The progress round the route, used in smooth mode. private int progressNum; // the current waypoint number, used in point-to-point mode. private Vector3 lastPosition; // Used to calculate current speed (since we may not have a rigidbody component) private float speed; // current speed of this object (calculated from delta since last frame) // setup script properties private void Start() { // We use an object to represent the point at which we should aim, and this point takes into account the upcoming speed change // This allows the component to communicate with AI without further dependency // we use a transform to represent the point to aim for, and the point which // is considered for upcoming changes-of-speed. This allows this component // to communicate this information to the AI without requiring further dependencies. // You can manually create an object and pay it to the component and AI, so that the component can update it, and AI can read data from it // You can manually create a transform and assign it to this component *and* the AI, // then this component will update it, and the AI can read it. if (target == null) { target = new GameObject(name + " Waypoint Target").transform; } Reset(); } // Reset the object to the appropriate value // reset the object to sensible values public void Reset() { progressDistance = 0; progressNum = 0; if (progressStyle == ProgressStyle.PointToPoint) { target.position = circuit.Waypoints[progressNum].position; target.rotation = circuit.Waypoints[progressNum].rotation; } } private void Update() { if (progressStyle == ProgressStyle.SmoothAlongRoute) { // Smooth path point mode // Determine where we should aim // This is different from the current progress position. This is the middle of the two approaches // We use interpolation to simply smooth the speed // determine the position we should currently be aiming for // (this is different to the current progress position, it is a a certain amount ahead along the route) // we use lerp as a simple way of smoothing out the speed over time. if (Time.deltaTime > 0) { speed = Mathf.Lerp(speed, (lastPosition - transform.position).magnitude/Time.deltaTime, Time.deltaTime); } // Offset a certain distance forward according to the distance to obtain the path point target.position = circuit.GetRoutePoint(progressDistance + lookAheadForTargetOffset + lookAheadForTargetFactor*speed) .position; // Path point direction adjustment. The calculation is repeated here. Why not cache? target.rotation = Quaternion.LookRotation( circuit.GetRoutePoint(progressDistance + lookAheadForSpeedOffset + lookAheadForSpeedFactor*speed) .direction); // Get unshifted path points // get our current progress along the route progressPoint = circuit.GetRoutePoint(progressDistance); // If the vehicle moves beyond the path point, move the path point forward Vector3 progressDelta = progressPoint.position - transform.position; if (Vector3.Dot(progressDelta, progressPoint.direction) < 0) { progressDistance += progressDelta.magnitude*0.5f; } // Record location lastPosition = transform.position; } else { // Point to point mode, increase the distance if it is close enough // point to point mode. Just increase the waypoint if we're close enough: // If the distance is less than the threshold, move the path point to the next Vector3 targetDelta = target.position - transform.position; if (targetDelta.magnitude < pointToPointThreshold) { progressNum = (progressNum + 1)%circuit.Waypoints.Length; } // Set the position and rotation direction of the path object target.position = circuit.Waypoints[progressNum].position; target.rotation = circuit.Waypoints[progressNum].rotation; // The distance calculation is the same as that of the plane slip path point mode // get our current progress along the route progressPoint = circuit.GetRoutePoint(progressDistance); Vector3 progressDelta = progressPoint.position - transform.position; if (Vector3.Dot(progressDelta, progressPoint.direction) < 0) { progressDistance += progressDelta.magnitude; } lastPosition = transform.position; } } private void OnDrawGizmos() { // Painting Gizmos if (Application.isPlaying) { Gizmos.color = Color.green; Gizmos.DrawLine(transform.position, target.position); // Connection between vehicle and path object Gizmos.DrawWireSphere(circuit.GetRoutePosition(progressDistance), 1); // Draw a sphere on a smooth path point Gizmos.color = Color.yellow; Gizmos.DrawLine(target.position, target.position + target.forward); // Draw the direction of the path object } } } }

It can be seen that the main purpose of this script is to update the status of target, which is the previously mentioned m_Target is also a WaypointTargetObject that has not been used in the scene. Here comes the question again. What is the basis of this script to update the status of target? yes circuit.GetRoutePoint(). What is this thing? If we look at the type of circuit, we can get the answer:

namespace UnityStandardAssets.Utility { public class WaypointCircuit : MonoBehaviour { // The main function of the management path point class is to obtain the path point on the closed path according to the path value public WaypointList waypointList = new WaypointList(); [SerializeField] private bool smoothRoute = true; private int numPoints; private Vector3[] points; private float[] distances; public float editorVisualisationSubsteps = 100; public float Length { get; private set; } public Transform[] Waypoints { get { return waypointList.items; } } //this being here will save GC allocs private int p0n; private int p1n; private int p2n; private int p3n; private float i; private Vector3 P0; private Vector3 P1; private Vector3 P2; private Vector3 P3; // Use this for initialization private void Awake() { if (Waypoints.Length > 1) { // Cache path points and paths CachePositionsAndDistances(); } numPoints = Waypoints.Length; } public RoutePoint GetRoutePoint(float dist) { // Calculate the path point and its direction after interpolation // position and direction Vector3 p1 = GetRoutePosition(dist); Vector3 p2 = GetRoutePosition(dist + 0.1f); Vector3 delta = p2 - p1; return new RoutePoint(p1, delta.normalized); } public Vector3 GetRoutePosition(float dist) { int point = 0; // Get the length of a week if (Length == 0) { Length = distances[distances.Length - 1]; } // Specify dist in [0,Length] dist = Mathf.Repeat(dist, Length); // From the starting point, find the road section where dist is located while (distances[point] < dist) { ++point; } // Get the two path points closest to dist // get nearest two points, ensuring points wrap-around start & end of circuit p1n = ((point - 1) + numPoints)%numPoints; p2n = point; // Get the percentage of the distance between two points // found point numbers, now find interpolation value between the two middle points i = Mathf.InverseLerp(distances[p1n], distances[p2n], dist); if (smoothRoute) { // Using smooth Catmull ROM curves // smooth catmull-rom calculation between the two relevant points // Then get the nearest two points, a total of four, which are used to calculate the Catmull ROM curve // get indices for the surrounding 2 points, because // four points are required by the catmull-rom function p0n = ((point - 2) + numPoints)%numPoints; p3n = (point + 1)%numPoints; // It seems that when there are only three path points, the calculated two new path points will coincide // 2nd point may have been the 'last' point - a dupe of the first, // (to give a value of max track distance instead of zero) // but now it must be wrapped back to zero if that was the case. p2n = p2n%numPoints; P0 = points[p0n]; P1 = points[p1n]; P2 = points[p2n]; P3 = points[p3n]; // Calculating Catmull ROM curve // Why is the i value here the percentage value of points 1 and 2? Why didn't it come from 0 or 3? return CatmullRom(P0, P1, P2, P3, i); } else { // simple linear lerp between the two points: p1n = ((point - 1) + numPoints)%numPoints; p2n = point; return Vector3.Lerp(points[p1n], points[p2n], i); } } private Vector3 CatmullRom(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float i) { // Magic code, calculating Catmull ROM curve // (actually, google has a formula in one click.) // comments are no use here... it's the catmull-rom equation. // Un-magic this, lord vector! return 0.5f * ((2*p1) + (-p0 + p2)*i + (2*p0 - 5*p1 + 4*p2 - p3)*i*i + (-p0 + 3*p1 - 3*p2 + p3)*i*i*i); } private void CachePositionsAndDistances() { // Convert the coordinates of each point and the total distance to a point into an array // The value in the distance array indicates the distance from the starting point to the nth path point, the last is the starting point, and the distance is one week instead of 0 // transfer the position of each point and distances between points to arrays for // speed of lookup at runtime points = new Vector3[Waypoints.Length + 1]; distances = new float[Waypoints.Length + 1]; float accumulateDistance = 0; for (int i = 0; i < points.Length; ++i) { var t1 = Waypoints[(i)%Waypoints.Length]; var t2 = Waypoints[(i + 1)%Waypoints.Length]; if (t1 != null && t2 != null) { Vector3 p1 = t1.position; Vector3 p2 = t2.position; points[i] = Waypoints[i%Waypoints.Length].position; distances[i] = accumulateDistance; accumulateDistance += (p1 - p2).magnitude; } } } private void OnDrawGizmos() { DrawGizmos(false); } private void OnDrawGizmosSelected() { DrawGizmos(true); } private void DrawGizmos(bool selected) { waypointList.circuit = this; if (Waypoints.Length > 1) { numPoints = Waypoints.Length; CachePositionsAndDistances(); Length = distances[distances.Length - 1]; Gizmos.color = selected ? Color.yellow : new Color(1, 1, 0, 0.5f); Vector3 prev = Waypoints[0].position; if (smoothRoute) { for (float dist = 0; dist < Length; dist += Length/editorVisualisationSubsteps) { Vector3 next = GetRoutePosition(dist + 1); Gizmos.DrawLine(prev, next); prev = next; } Gizmos.DrawLine(prev, Waypoints[0].position); } else { for (int n = 0; n < Waypoints.Length; ++n) { Vector3 next = Waypoints[(n + 1)%Waypoints.Length].position; Gizmos.DrawLine(prev, next); prev = next; } } } } [Serializable] public class WaypointList { public WaypointCircuit circuit; public Transform[] items = new Transform[0]; } // Path point structure public struct RoutePoint { public Vector3 position; public Vector3 direction; public RoutePoint(Vector3 position, Vector3 direction) { this.position = position; this.direction = direction; } } } }

The data used by this class comes from the Waypoints object and its children in the scene, and this class is mounted on Waypoints. So it's clear:

  1. A series of space objects are defined in the scene. They have regular coordinates, can form a closed circle path, and assign it to waypoint circuit
  2. Waypoint circuit caches these data and converts their coordinates and distances into arrays for easy calculation
  3. WaypointProgressTracker calls the GetRoutePoint method of WaypointCircuit and its public properties to calculate and update the coordinates of WaypointTargetObject
  4. CarAIControl uses the coordinates of WaypointTargetObject and vehicle data to call the Move method of CarController to update vehicle data

A complete AI logic chain, from data to decision-making to data, is analyzed. The implementation of the algorithm is clearly identified in the code along with Unity's comments. In addition, there are also some Editor and Gizmos codes. Because these codes still report Error when I run, I don't want to analyze them. If you are interested, you can go to AssetStore to download them, or clone the Git warehouse where I have comments: https://github.com/t61789/StandardAssetWithAnnotation

This is the end of the racing game scene. The next chapter analyzes the third person scene or the first person scene to see the mood.

26 June 2020, 23:41 | Views: 7630

Add new comment

For adding a comment, please log in
or create account

0 comments